Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,59 @@ e o projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/).

## [Não lançado]

> Correção do contrato de webhooks contra a API real, provado por sonda ao vivo
> (2026-07-02/03, três contas). O contrato correto sempre esteve nos specs oficiais
> (`openapi/nf-servico-v1.yaml` e equivalentes) — o recurso manuscrito havia
> divergido deles.

### Corrigido

- **O CRUD de webhooks funcionava 0% das vezes**: a rota company-scoped
`/v1/companies/{id}/webhooks` retorna 404 na API atual. O contrato real é
account-scoped (`/v2/webhooks`) e exige o request envelopado em
`{ "webHook": {...} }` (sem ele responde
`400 "missing required properties: 'webHook'"`), devolvendo a resposta também
envelopada. Os novos métodos account-scoped envelopam o request
(create/update) e desembrulham as respostas (create/retrieve/update/list),
com fallback defensivo para corpo cru.

### Adicionado

- Métodos account-scoped em `client.webhooks`: `list_account_webhooks`,
`create_account_webhook`, `retrieve_account_webhook`,
`update_account_webhook`, `delete_account_webhook`,
`delete_all_account_webhooks` (destrutivo, nome propositalmente distinto),
`ping_account_webhook` e `fetch_event_types`.
- Value object **`Nfe::AccountWebhook`** (`Data.define`, com RBS) com o shape
real da API: `uri`, `content_type`, `secret` (32–64 caracteres, ecoado no
create e omitido nas leituras), `filters`, `insecure_ssl`, `headers`,
`properties`, `status`, `created_on`, `modified_on`. Nota: o spec declara
`contentType`/`status` como enums inteiros, mas a API serializa strings
(`"json"`, `"Active"`) — o DTO segue o fio real.
- `fetch_event_types` retorna os event types reais de
`GET /v2/webhooks/eventTypes` (46 ids ao vivo, padrão
`service_invoice.*`/`product_invoice.*`/`consumer_invoice.*`).
- Teste de alinhamento (RSpec + Psych) amarrando o `Nfe::AccountWebhook` ao
schema de `/v2/webhooks` em `openapi/nf-servico-v1.yaml` — um sync de spec
que mude o contrato de webhooks quebra a suíte em vez de driftar.
- YARD do `create_account_webhook` documenta a verificação de URI na criação
(a NFE.io faz um ping e exige resposta 2xx) e o `secret` de 32–64 caracteres.
- YARD do `update_account_webhook` documenta que o `PUT` é substituição
integral (confirmado ao vivo em 2026-07-03): campos omitidos voltam ao
padrão — update sem `status` **desativa o webhook**. Envie o objeto completo
(parta do retrieve).

### Deprecado

- Métodos company-scoped de webhooks (`list`, `create`, `retrieve`, `update`,
`delete`, `test` sobre `/v1/companies/{id}/webhooks`): a rota retorna **404**
na API atual (confirmado em três contas, 2026-07-02/03). Use os equivalentes
account-scoped. O comportamento não mudou; remoção fica para a próxima major.
- `Nfe::WebhookSubscription` (`url`/`events`/`active`) e
`get_available_events`/`AVAILABLE_EVENTS` (literais `invoice.*`): shapes e
eventos que a API real rejeita ou desconhece. Use `Nfe::AccountWebhook` e
`fetch_event_types`.

## [1.0.0] - 2026-07-02

### Adicionado
Expand Down
24 changes: 17 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ A `v1` expõe **17 recursos canônicos** no `Nfe::Client` (mais 2 addons RTC).
| `companies` | `api.nfe.io` (`/v1`) | Empresas + certificado | `create`, `retrieve`, `list`, `update`, `remove`, `upload_certificate`, `get_certificate_status` |
| `legal_people` | `api.nfe.io` (`/v1`) | Pessoas jurídicas (tomadores) | `create`, `retrieve`, `list`, `update`, `delete`, `create_batch`, `find_by_tax_number` |
| `natural_people` | `api.nfe.io` (`/v1`) | Pessoas físicas (tomadores) | `create`, `retrieve`, `list`, `update`, `delete`, `create_batch`, `find_by_tax_number` |
| `webhooks` | `api.nfe.io` (`/v1`) | Assinaturas de webhook | `create`, `retrieve`, `list`, `update`, `delete`, `test`, `verify_signature` |
| `webhooks` | `api.nfe.io` (`/v2`, conta) | Webhooks da conta | `create_account_webhook`, `retrieve_account_webhook`, `list_account_webhooks`, `update_account_webhook`, `delete_account_webhook`, `ping_account_webhook`, `fetch_event_types`, `verify_signature` |
| `product_invoices` | `api.nfse.io` (`/v2`) | NF-e | `create`, `create_with_state_tax`, `list`, `retrieve`, `cancel`, `send_correction_letter`, `disable`, `download_*` |
| `consumer_invoices` | `api.nfse.io` (`/v2`) | NFC-e | `create`, `create_with_state_tax`, `list`, `retrieve`, `cancel`, `disable_range`, `download_pdf`/`download_xml` |
| `transportation_invoices` | `api.nfse.io` (`/v2`) | CT-e (recepção) | `enable`, `disable`, `get_settings`, `retrieve`, `download_xml`, `get_event` |
Expand Down Expand Up @@ -374,16 +374,26 @@ Devolvem bytes: `service_invoices`, `consumer_invoices`,

## Webhooks

Crie a assinatura e **verifique a assinatura** de cada entrega.
Crie o webhook (escopo da **conta**, `/v2/webhooks`) e **verifique a
assinatura** de cada entrega. A NFE.io pinga a `uri` na criação e exige 2xx;
descubra os filtros válidos com `fetch_event_types`.

```ruby
client.webhooks.create("co_1", {
url: "https://minha-app.com/webhooks/nfe",
events: ["invoice.issued", "invoice.cancelled", "invoice.failed"],
secret: ENV["NFE_WEBHOOK_SECRET"]
})
client.webhooks.create_account_webhook(
uri: "https://minha-app.com/webhooks/nfe",
contentType: "json",
secret: ENV["NFE_WEBHOOK_SECRET"], # 32–64 caracteres
filters: ["service_invoice.issued_successfully", "service_invoice.issued_error"],
status: "Active"
)
```

> ⚠️ `update_account_webhook` faz **PUT integral**: campos omitidos voltam ao
> padrão — um update sem `status` desativa o webhook. Parta do
> `retrieve_account_webhook` e envie o objeto completo. Os métodos
> company-scoped (`create`/`list`/... com `company_id`) estão **deprecated** —
> a rota `/v1/companies/{id}/webhooks` retorna 404 na API atual.

A verificação é **HMAC-SHA1 sobre os bytes crus** da requisição (header
`X-Hub-Signature`, comparação case-insensitive e timing-safe). Leia o corpo bruto
**antes** de fazer parse do JSON — reserializar (`payload.to_json`) muda
Expand Down
131 changes: 95 additions & 36 deletions docs/recursos/webhooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,82 +3,141 @@ title: Webhooks no SDK Ruby da NFE.io
sidebar_label: Webhooks
sidebar_position: 10
slug: webhooks
description: CRUD de assinaturas de webhook por empresa, disparo de teste, lista de eventos disponíveis e verificação de assinatura no SDK Ruby da NFE.io.
description: CRUD de webhooks no escopo da conta (/v2/webhooks), ping de teste, tipos de eventos ao vivo e verificação de assinatura no SDK Ruby da NFE.io.
---

# Webhooks

O recurso `webhooks` gerencia as assinaturas de webhook **escopadas por empresa**,
sob `/companies/{id}/webhooks`. Faz parte da família `main`, servida por
`api.nfe.io` (`/v1`), e usa a `api_key`. Além do CRUD, expõe um disparo de teste,
a lista estática de eventos e a verificação de assinatura.
O recurso `webhooks` gerencia os webhooks **no escopo da conta autenticada**,
sob `/v2/webhooks` em `api.nfe.io` (família `main`, `api_key`). A API envelopa
os requests de create/update e as respostas de objeto único em `webHook` — o
SDK envelopa e desembrulha automaticamente. Além do CRUD, expõe um ping de
teste, a lista **ao vivo** de tipos de eventos e a verificação de assinatura.

:::warning Métodos por empresa estão deprecated
Os métodos company-scoped (`list`, `create`, `retrieve`, `update`, `delete`,
`test` sob `/v1/companies/{id}/webhooks`) estão **deprecated**: a rota retorna
**404** na API atual (confirmado em três contas, 2026-07-02/03). Use os
equivalentes `*_account_webhook*` abaixo. A remoção fica para a próxima major.
:::

## Métodos públicos

```ruby
webhooks.list(company_id) # => Nfe::ListResponse
webhooks.create(company_id, data) # => Nfe::WebhookSubscription
webhooks.retrieve(company_id, webhook_id) # => Nfe::WebhookSubscription
webhooks.update(company_id, webhook_id, data) # => Nfe::WebhookSubscription
webhooks.delete(company_id, webhook_id) # => nil
webhooks.test(company_id, webhook_id) # => { success: bool, message: String? }
webhooks.get_available_events # => Array<String>
webhooks.list_account_webhooks # => Nfe::ListResponse
webhooks.create_account_webhook(data) # => Nfe::AccountWebhook
webhooks.retrieve_account_webhook(webhook_id) # => Nfe::AccountWebhook
webhooks.update_account_webhook(webhook_id, data) # => Nfe::AccountWebhook
webhooks.delete_account_webhook(webhook_id) # => nil
webhooks.delete_all_account_webhooks # => nil (⚠️ apaga TODOS)
webhooks.ping_account_webhook(webhook_id) # => nil
webhooks.fetch_event_types # => Array<String>
webhooks.verify_signature(payload:, signature:, secret:) # => Boolean
```

Os eventos disponíveis (`get_available_events`) são:
`invoice.issued`, `invoice.cancelled`, `invoice.failed`, `invoice.processing`,
`company.created`, `company.updated` e `company.deleted`.
`Nfe::AccountWebhook` é um `Data.define` imutável com o shape real do fio:
`id`, `uri`, `content_type`, `secret`, `filters`, `insecure_ssl`, `headers`,
`properties`, `status`, `created_on`, `modified_on`. O `secret` (32–64
caracteres) é ecoado **apenas no create** e omitido nas leituras.

## Exemplos

### Criar e testar uma assinatura
### Criar um webhook

```ruby
require "nfe"

client = Nfe::Client.new(api_key: ENV.fetch("NFE_API_KEY"))
company_id = "55df4dc6b6cd9007e4f13ee8"

hook = client.webhooks.create(
company_id,
url: "https://app.exemplo.com.br/webhooks/nfe",
events: ["invoice.issued", "invoice.failed"],
secret: ENV.fetch("NFE_WEBHOOK_SECRET"),
active: true

hook = client.webhooks.create_account_webhook(
uri: "https://app.exemplo.com.br/webhooks/nfe",
contentType: "json",
secret: ENV.fetch("NFE_WEBHOOK_SECRET"), # 32–64 caracteres
filters: ["service_invoice.issued_successfully", "service_invoice.issued_error"],
status: "Active"
)

# Dispara uma entrega sintética para validar que o endpoint responde:
result = client.webhooks.test(company_id, hook.id)
result[:success]
hook.id # GUID do webhook
hook.secret # ecoado só aqui — guarde-o agora
```

:::warning A NFE.io pinga a URI na criação
No `create`, a NFE.io faz um **ping de verificação na `uri` e exige resposta
2xx** — o endpoint precisa estar no ar **antes** de criar o webhook, ou a
criação falha.
:::

### Descobrir os tipos de eventos (ao vivo)

```ruby
client.webhooks.fetch_event_types
# => ["service_invoice.issued_successfully", "service_invoice.cancelled_error",
# "product_invoice.issued", "consumer_invoice.issued", ...] (46 ids)
```

Os ids seguem o padrão `service_invoice.*` / `product_invoice.*` /
`consumer_invoice.*` e são os valores válidos para `filters`. Os literais
antigos `invoice.*` (de `get_available_events`, deprecated) **não existem** na
API real.

### Atualizar — o PUT é substituição integral

```ruby
current = client.webhooks.retrieve_account_webhook(hook.id)

client.webhooks.update_account_webhook(hook.id, {
uri: current.uri,
contentType: current.content_type,
status: current.status, # sem isso o hook é DESATIVADO
filters: ["service_invoice.issued_successfully"]
})
```

:::warning Campos omitidos voltam ao padrão
`PUT /v2/webhooks/{id}` **substitui o objeto inteiro** (confirmado ao vivo):
um update sem `status` desativa o webhook. Sempre parta do `retrieve` e envie
o objeto completo.
:::

### Testar, deletar

```ruby
client.webhooks.ping_account_webhook(hook.id) # entrega de teste (204)
client.webhooks.delete_account_webhook(hook.id) # remove um webhook

# ⚠️ DESTRUTIVO: remove TODOS os webhooks da conta — método propositalmente
# distinto do delete unitário:
client.webhooks.delete_all_account_webhooks
```

### Verificar a assinatura de um payload recebido

```ruby
ok = client.webhooks.verify_signature(
payload: request.raw_post,
signature: request.get_header("HTTP_X_NFE_SIGNATURE"),
signature: request.get_header("HTTP_X_HUB_SIGNATURE"),
secret: ENV.fetch("NFE_WEBHOOK_SECRET")
)

head :unauthorized unless ok
```

:::tip Use o módulo `Nfe::Webhook` no handler
`webhooks.verify_signature` é uma delegação fina para `Nfe::Webhook.verify_signature`,
oferecida por paridade com o SDK Node. No seu controlador HTTP, prefira chamar o
módulo diretamente — ele não exige um `Nfe::Client` e **nunca** levanta exceção,
apenas devolve `true`/`false`. Veja o guia de [Webhooks](../webhooks.md).
`webhooks.verify_signature` é uma delegação fina para `Nfe::Webhook.verify_signature`.
No seu controlador HTTP, prefira chamar o módulo diretamente — ele não exige um
`Nfe::Client` e **nunca** levanta exceção, apenas devolve `true`/`false`. Veja o
guia de [Webhooks](../webhooks.md).
:::

:::note `list` tolera vários formatos
`list(company_id)` aceita a resposta da API como array puro, embrulhada em `data`
ou em um envelope `webhooks`, sempre devolvendo um `Nfe::ListResponse`.
:::note Fonte do contrato
O contrato acima vem do spec OpenAPI (`openapi/nf-servico-v1.yaml`) validado
por sonda ao vivo contra `api.nfe.io`, e está amarrado por um teste de
alinhamento na suíte — nunca de outro SDK. Única divergência deliberada: o
spec declara `contentType`/`status` como enums inteiros, mas a API serializa
strings (`"json"`, `"Active"`); o SDK segue o fio real.
:::

## Veja também

- [Webhooks (guia)](../webhooks.md) — verificação de assinatura e recebimento por push.
- [Empresas](./companies.md) — o escopo (`company_id`) das assinaturas.
- [Emissão assíncrona e polling](../async-and-polling.md) — alternativa por polling.
6 changes: 5 additions & 1 deletion docs/webhooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ event = Nfe::Webhook.construct_event(
secret: ENV.fetch("NFE_WEBHOOK_SECRET")
)

event.type # ex.: "invoice.issued"
event.type # ex.: "service_invoice.issued_successfully"
event.data # Hash com o payload (a chave "payload" ou "data" do envelope)
event.id # id estável para deduplicação, ou nil
event.created_at # timestamp da entrega como String, ou nil
Expand Down Expand Up @@ -151,6 +151,10 @@ pode gerar efeitos colaterais duplicados.

## Próximos passos

- [Webhooks (recurso)](./recursos/webhooks.md) — crie e gerencie os webhooks da
conta (`/v2/webhooks`), descubra os tipos de eventos ao vivo com
`fetch_event_types` e veja os cuidados do create (ping 2xx na URI) e do
update (PUT é substituição integral).
- [Primeiros passos](./getting-started.md) — emissão e polling como alternativa ao push.
- [Paginação](./pagination.md) — percorra listas de notas.
- [Downloads](./downloads.md) — baixe PDF/XML das notas.
50 changes: 50 additions & 0 deletions lib/nfe/resources/dto/account_webhook.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# frozen_string_literal: true

module Nfe
# Immutable value object for an account-level webhook, as returned by the
# +/v2/webhooks+ API (account scope, envelope +{"webHook": {...}}+ on the
# wire). This is the shape the live API accepts and returns — confirmed by
# probe against +api.nfe.io+ (2026-07-02/03) and matching the schema in
# +openapi/nf-servico-v1.yaml+.
#
# Wire-format note: the OpenAPI spec declares +contentType+/+status+ as int
# enums (0/1), but the API serializes strings (+"json"+, +"Active"+) — this
# DTO follows the wire. +secret+ (32–64 chars) is echoed only on create and
# omitted on reads, so it is +nil+ on retrieve/list.
#
# {from_api} maps API camelCase onto snake_case, drops unknown keys, and is
# nil-tolerant (+from_api(nil)+ returns +nil+).
class AccountWebhook < Data.define(
:id,
:uri,
:content_type,
:secret,
:filters,
:insecure_ssl,
:headers,
:properties,
:status,
:created_on,
:modified_on
)
# @param payload [Hash, nil] the unwrapped webhook object.
# @return [Nfe::AccountWebhook, nil] +nil+ when +payload+ is +nil+.
def self.from_api(payload)
return nil if payload.nil?

new(
id: payload["id"],
uri: payload["uri"],
content_type: payload["contentType"],
secret: payload["secret"],
filters: payload["filters"],
insecure_ssl: payload["insecureSsl"],
headers: payload["headers"],
properties: payload["properties"],
status: payload["status"],
created_on: payload["createdOn"],
modified_on: payload["modifiedOn"]
)
end
end
end
6 changes: 6 additions & 0 deletions lib/nfe/resources/dto/webhook.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ module Nfe
#
# {from_api} maps API camelCase onto snake_case, drops unknown keys, and is
# nil-tolerant (+from_api(nil)+ returns +nil+).
#
# @deprecated This shape (+url+/+events+/+active+) is rejected by the live
# API (+400 "The Uri field is required"+), and the company-scoped route
# that would return it responds 404 (confirmed on three accounts,
# 2026-07-02/03). Use {Nfe::AccountWebhook} with the account-scoped
# methods on {Nfe::Resources::Webhooks}.
class WebhookSubscription < Data.define(
:id,
:url,
Expand Down
Loading