# Webhooks

`POST /v1/webhook_endpoints` (permission `webhooks:write`; `Idempotency-Key` required) creates a destination (at most 20 per organization; the test and the live half have their own). Its signing `secret` is in that answer and nowhere else, ever: store it then. If it is lost, rotate it.

## What arrives

A `POST` with a JSON body, the public envelope of the event: `id`, `type`, `created_at`, `mode`, `organization_id`, `data`, and `simulated: true` only for an event fabricated in test mode. `GET /v1/event_types` shows an example of every type.

## Verify every message (Standard Webhooks)

Three headers travel with it: `webhook-id`, `webhook-timestamp` (seconds) and `webhook-signature`.
- The signed content is `<webhook-id>.<webhook-timestamp>.<body>`, with the body EXACTLY as received (the raw bytes, before any JSON parsing).
- The signature is `v1,<base64 of HMAC-SHA256(signed content, key)>`, where the key is the standard-base64 decoding of the secret after its `whsec_` prefix.
- `webhook-signature` may hold several signatures separated by a space (during a rotation): accept the message if ANY matches. Compare in constant time.
- Reject a message whose timestamp is more than a few minutes away from now: it is a replay.
Use a `standardwebhooks` library instead of writing this by hand; the signatures of this API are proven against the official one.

## Discard duplicates by `webhook-id`

Delivery is at-least-once: a retry, and a resend you ask for, carry the SAME `webhook-id` (the id of the event). Keep the ids you processed and answer `2xx` to one you already have. Answer fast and do the work afterwards: a slow answer counts as a failure and is retried.

## Reconcile with the event stream

A webhook can be lost while your receiver is down. The truth is the event stream: `GET /v1/events` (permission `events:read`) by cursor returns everything in order, including what no webhook delivered. A receiver that stores the last cursor it reconciled never misses an event.

## Looking at what happened

- `POST /v1/webhook_endpoints/{id}/test` sends a signed sample now and answers what your receiver said.
- `GET /v1/webhook_endpoints/{id}/deliveries` is the delivery log of a destination.
- `GET /v1/events/{id}/deliveries` says where one event went; `POST /v1/events/{id}/resend` sends it again.
- `POST /v1/webhook_endpoints/{id}/rotate_secret` makes a new secret; the previous one keeps signing for a while, so both verify during the change.
- In a test organization, `POST /v1/test/events` fabricates an event to exercise your receiver. It is marked `simulated`.

A destination that keeps failing is disabled by the service (event `webhook_endpoint.disabled`), and the administrators are told by email.

## Event types

| Type | Subject | What happened | Alerts the administrators |
| --- | --- | --- | --- |
| `organization.created` | organization | An organization and its test twin were created. |  |
| `organization.updated` | organization | The name of the organization changed. |  |
| `organization.archived` | organization | The organization was archived. |  |
| `member.added` | member | A person became a member. |  |
| `member.updated` | member | The role of a member changed. |  |
| `member.removed` | member | A member was removed. Carries the membership as it was. |  |
| `service_account.created` | service_account | A service account was created. |  |
| `service_account.updated` | service_account | The name or the scopes of a service account changed. |  |
| `service_account.disabled` | service_account | A service account was disabled; all its keys stopped working. |  |
| `api_key.created` | api_key | A key was created. The secret is never in the event. |  |
| `api_key.revoked` | api_key | A key was revoked. Carries the key as it was, without its secret. |  |
| `issuer.created` | issuer | The fiscal data of the issuer was set for the first time. |  |
| `issuer.updated` | issuer | The fiscal data of the issuer changed. |  |
| `series.created` | series | A series was created. |  |
| `invoice.created` | invoice | An invoice was accepted by the API and took its number. It is not yet sent. |  |
| `invoice.sent` | invoice | The provider received the invoice; SUNAT has not answered yet. |  |
| `invoice.accepted` | invoice | SUNAT accepted the invoice. |  |
| `invoice.rejected` | invoice | SUNAT rejected the invoice. Its number was not used. | yes |
| `invoice.failed` | invoice | The invoice cannot go on by itself and needs a correction. | yes |
| `invoice.stalled` | invoice | The invoice has been in process longer than expected. Nothing is lost; someone should look. | yes |
| `invoice.held` | invoice | The provider gave an answer the service cannot interpret. The invoice is held in process: nothing is resent, and the staff of the service was told. |  |
| `invoice.released` | invoice | The staff of the service released a held invoice: it first asks the provider, then goes on. |  |
| `issuer.activated` | issuer | The issuer was activated for production: its RUC is locked to it and it can issue for real. |  |
| `provider_connection.created` | provider_connection | A connection to a provider was added to the issuer. |  |
| `provider_connection.updated` | provider_connection | The configuration or the state of a provider connection changed. |  |
| `provider.account_blocked` | provider | The provider refuses every operation of the account. Invoices stay in process and go out when it recovers. |  |
| `provider.account_recovered` | provider | The provider account works again; what was waiting is being sent. |  |
| `invoice.void_requested` | invoice | A void of the invoice was requested. The invoice is still accepted. |  |
| `invoice.voided` | invoice | SUNAT accepted the void. The invoice is voided. |  |
| `invoice.void_rejected` | invoice | SUNAT rejected the void. The invoice is still accepted. | yes |
| `invoice.void_held` | invoice | The provider gave an answer about the void that the service cannot interpret. The void is held: nothing is resent, and the staff of the service was told. |  |
| `invoice.void_released` | invoice | The staff of the service released a held void: it first asks the provider, then goes on. |  |
| `invoice.void_failed` | invoice | The void could not be completed (for example, its window expired). The invoice is still accepted. | yes |
| `invoice.void_stalled` | invoice | The void of the invoice has been in process longer than expected. The invoice is still accepted; nothing is lost, and someone should look. | yes |
| `invoice.reopened` | invoice | A failed or rejected invoice was corrected and is in process again. Its previous attempt is kept in its history. |  |
| `invoice.externally_reversed` | invoice | A person recorded that the invoice was reversed by hand at the provider. A note, not a fiscal state. |  |
| `invoice.files_stored` | invoice | The files of the invoice (XML, CDR, PDF) are stored and can be downloaded. |  |
| `invoice.file_failed` | invoice | A file of the invoice could not be stored or rendered. It can be asked again. |  |
| `invoice.email_sent` | invoice | An email about the invoice was sent. |  |
| `invoice.email_not_sent` | invoice | An email about the invoice was deliberately not sent; the event says why. Its content stays readable. |  |
| `invoice.email_failed` | invoice | An email about the invoice could not be sent after retrying. |  |
| `invoice.email_held` | invoice | An email to the customer is held because the PDF it carries could not be rendered. It is never sent without it, and goes out by itself once the PDF is rendered again and stored. | yes |
| `webhook_endpoint.created` | webhook_endpoint | A webhook destination was created. The secret is never in the event. |  |
| `webhook_endpoint.updated` | webhook_endpoint | The URL, the subscribed types or the state of a webhook destination changed. |  |
| `webhook_endpoint.deleted` | webhook_endpoint | A webhook destination was deleted. Carries the destination as it was, without its secret. |  |
| `webhook_endpoint.disabled` | webhook_endpoint | The service disabled a webhook destination that kept failing. Nothing is delivered to it until it is enabled again. | yes |
| `webhook_endpoint.secret_rotated` | webhook_endpoint | The signing secret of a webhook destination was rotated. The previous one still signs until it expires. |  |
| `invitation.created` | invitation | A person was invited to the organization. The token is never in the event. |  |
| `invitation.resent` | invitation | An invitation was sent again with a new token; the previous token stopped working. The token is never in the event. |  |
| `invitation.accepted` | invitation | An invitation was accepted; the person is now a member. |  |
| `invitation.revoked` | invitation | An invitation was revoked. Carries the invitation as it was, without its token. |  |

Alerts are grouped: one email per organization for everything that happened in 15 minutes, naming the first 5 cases. A simulated event never raises one.
