> ## Documentation Index
> Fetch the complete documentation index at: https://docs.payra.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Register a URL and Payra posts every payment state change to it, signed, with retries.

A payment keeps changing after the create answered. A `processing` charge is confirmed by the
processor later, and a bank debit stays `processing` until it settles in the processor's next batch; a
`succeeded` one comes back as `returned` on a bank return or a chargeback days after; a void in the dashboard turns
it `canceled`; a [refund](/refunds) of it succeeds or fails. Rather than
polling `GET /payments/{id}`, register a **webhook endpoint** and Payra posts each change to it.

Endpoints can also be added, paused and deleted from the dashboard, in **Settings → Integrations →
Webhooks**, which shows the delivery log and lets you resend any finished delivery to an endpoint that is still
enabled.

<Steps>
  <Step title="Register an endpoint">
    ```bash theme={null}
    curl -X POST https://api-dashboard.payra.com/v1/webhook-endpoints \
      -H "Authorization: Bearer sk_test_..." \
      -H "Content-Type: application/json" \
      -d '{
        "url": "https://example.com/payra/webhooks",
        "enabled_events": ["payment.succeeded", "payment.failed", "payment.returned"],
        "description": "Order fulfilment"
      }'
    ```

    ```json Response theme={null}
    {
      "object": "webhook_endpoint",
      "id": "we_test_4kQ2mL7Hs1pXv4cR8tWzAbCd",
      "url": "https://example.com/payra/webhooks",
      "description": "Order fulfilment",
      "enabled_events": ["payment.succeeded", "payment.failed", "payment.returned"],
      "status": "enabled",
      "secret": "whsec_test_...",
      "livemode": false,
      "created_at": "2026-09-18T19:00:00.000Z"
    }
    ```

    **`secret` is in this response only.** Store it; it is what you verify deliveries with, and it
    is never shown again. `enabled_events` is a list of types, or `["*"]` for every type, present
    and future. The URL must be `https` on a publicly reachable host. A hostname that resolves to a
    private address is accepted when you save it, but every delivery to it fails with
    `last_error: "host not allowed"`.
  </Step>

  <Step title="Receive the delivery">
    Payra `POST`s the event to your URL:

    ```http theme={null}
    POST /payra/webhooks HTTP/1.1
    Content-Type: application/json
    Payra-Event-Id: evt_test_7Hs2kQ9mL1pXv4cR8tWzAbCd
    Payra-Signature: t=1789761602,v1=5257a869…
    User-Agent: Payra-Webhooks/1
    ```

    ```json Body theme={null}
    {
      "object": "event",
      "id": "evt_test_7Hs2kQ9mL1pXv4cR8tWzAbCd",
      "type": "payment.succeeded",
      "created_at": "2026-09-18T20:00:01.312Z",
      "livemode": false,
      "data": {
        "object": { "object": "payment", "id": "pay_test_7Hs2kQ9mL1pXv4cR8tWzAbCd", "status": "succeeded", "...": "..." }
      }
    }
    ```

    Answer any `2xx` within 15 seconds. Redirects are not followed.
  </Step>

  <Step title="Verify the signature, then act">
    Recompute the signature over the **raw** body and the signed time, compare in constant time,
    and refuse a delivery more than five minutes old. A header you cannot parse is a forgery, not
    an error: answer `400` and move on. Then deduplicate on `id` before doing anything: a retry
    and a resend carry the same event.

    ```js theme={null}
    import { createHmac, timingSafeEqual } from 'node:crypto';

    export function verify(secret, header, rawBody) {
      const parts = new Map((header ?? '').split(',').map((part) => part.split('=', 2)));
      const t = parts.get('t');
      const v1 = parts.get('v1');
      if (!t || !v1 || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
      const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
      const given = Buffer.from(v1, 'hex');
      return given.length === expected.length && timingSafeEqual(given, expected);
    }
    ```

    To rotate a secret, register a new endpoint and delete the old one once the new one receives
    events.

    Check your signature step against this vector before going live: secret
    `whsec_test_vector_secret_do_not_use` (the whole value is the HMAC key, prefix included), `t` `1758315851`,
    body `{"object":"event","id":"evt_test_1"}` → `v1` is
    `e5ca22ae17f9d57b866e45d25890fca17410ce3eb1ad6a7b5b565e2c54398c19`. That `t` is long past, so `verify()`
    above refuses the vector for its age: test the HMAC without the five-minute check.
  </Step>
</Steps>

## The event object

| Field         | Meaning                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`          | `evt_test_…` / `evt_live_…`. The same id on every delivery of this event, retried or resent                                                                                                                                                                                                                                                                                                                                                                         |
| `type`        | One of the types below                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `created_at`  | When the object reached this state, by Payra's ledger — not when the delivery was sent                                                                                                                                                                                                                                                                                                                                                                              |
| `data.object` | The [payment](/payments) or [refund](/refunds) **as it was when the event was filed**, rendered exactly like `GET /payments/{id}` or `GET /refunds/{id}`. `type` names the transition; the object can already be a step further along (a `payment.processing` filed just after the charge succeeded carries `status: succeeded`). Read `data.object.status` rather than inferring it from `type`; `GET /payments/{id}` or `GET /refunds/{id}` has the current state |

## Event types

One per `status` a payment or a refund reaches, starting with the one the create answered, plus the two a checkout session reaches. A card
charge approved synchronously emits `payment.succeeded` alone, and a refund confirmed synchronously
emits `refund.succeeded` alone: the create's own status is announced once, and there is no
`processing` or `pending` event for a state nobody observed. A bank debit is born `processing`, so it
emits `payment.processing` right after the create and `payment.succeeded` when it settles.

| Type                         | When                                                                                                                                                    |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `payment.processing`         | The processor has the charge but has not settled or answered it yet; a later event says how it ended. Every bank debit starts here                      |
| `payment.authorized`         | Approved but not captured. Not expected: every charge on this API is a sale                                                                             |
| `payment.succeeded`          | A card: captured. A bank debit: settled, sent to the bank in the processor's batch (not final: a return can follow)                                     |
| `payment.failed`             | Refused, or never reached the processor; `data.object.failure` says why                                                                                 |
| `payment.canceled`           | Voided in the dashboard before it settled                                                                                                               |
| `payment.returned`           | The money came back: a bank return (also straight from `processing`, when the bank refuses the debit) or a chargeback; `data.object.failure` says which |
| `refund.pending`             | The processor has the refund but has not confirmed it; a later event says how it ended                                                                  |
| `refund.succeeded`           | The money is on its way back                                                                                                                            |
| `refund.failed`              | The processor refused the refund; `data.object.failure` says why                                                                                        |
| `checkout_session.completed` | A [hosted checkout](/hosted-checkout) page took a payment; `data.object.payment` names it                                                               |
| `checkout_session.expired`   | The page's time ran out unpaid, or you expired it                                                                                                       |

For a `refund.*` event, `data.object` is the [refund](/refunds), and `data.object.payment` names
the payment it belongs to. A `["*"]` subscription receives these without being changed.

```json theme={null}
{
  "object": "event",
  "id": "evt_test_9mL1pXv4cR8tWzAbCdEf7Hs2",
  "type": "refund.succeeded",
  "created_at": "2026-09-22T20:00:01.312Z",
  "livemode": false,
  "data": {
    "object": {
      "object": "refund",
      "id": "re_test_3kQ2mL7Hs1pXv4cR8tWzAbCd",
      "payment": "pay_test_7Hs2kQ9mL1pXv4cR8tWzAbCd",
      "status": "succeeded",
      "amount": 2500,
      "currency": "USD",
      "reason": "Customer returned the item",
      "failure": null,
      "livemode": false,
      "created_at": "2026-09-22T20:00:00.000Z",
      "succeeded_at": "2026-09-22T20:00:01.312Z"
    }
  }
}
```

Only payments made through this API, and refunds of those payments, produce events; a refund made
in the dashboard on an API payment is announced like one made through `POST /refunds`. Payments
taken in the dashboard, the customer portal or a payment link have no `pay_` and are not announced,
and neither are their refunds.

## Retries

A delivery that does not get a `2xx` within 15 seconds — a timeout, a `4xx`, a `5xx`, a redirect —
is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours. After
the eighth attempt the delivery is `exhausted` and stops. Deliveries are **at least once and in no
guaranteed order**: two events for the same payment can arrive out of sequence, and the same event
can arrive twice. Deduplicate on `id`, and read `type` rather than assuming the order.

Deliveries to one workspace run in parallel, up to ten at a time; a slow endpoint queues your own
deliveries, never another merchant's. Answer fast and do the work after.

## An endpoint that stops answering

Payra pauses an endpoint when, for **three days**, no delivery to it got a `2xx` and nobody edited or
resumed it, and at least one delivery created before those three days ran its whole schedule against
it. Its `status` becomes `disabled`, deliveries still being retried are `canceled`, and no event is
sent to it until you resume it. Every event stays in
the delivery log. Whoever registered the endpoint in the dashboard is emailed; the **Webhook
Endpoint Paused** rule in Settings → Internal Notifications adds recipients. An endpoint registered with an
API key emails no one unless that rule is on, and it is off by default.

Once your server answers again, set the endpoint back to `enabled` (`POST /webhook-endpoints/{id}`
with `{ "status": "enabled" }`, or **Resume** in the dashboard) and resend what it missed with
`POST /events/{id}/resend`. Events filed while it was paused have no delivery to it, so the dashboard
log offers no Resend for them: use the API.

## The delivery log

`GET /events` lists every event your payments produced in this environment, newest first, whether
or not an endpoint was listening at the time; `type` filters, and `limit` and `starting_after` page
it like every other list. `GET /events/{id}` adds `deliveries`, oldest first: one entry per
delivery of the event, with `endpoint` (the `we_…` it went to), `origin` (`emit` or `resend`), `status` (`pending`, `succeeded`,
`exhausted`, `canceled`), `attempt_count`, `created_at`, `last_attempt_at`, `next_attempt_at`,
`last_response_status` and `last_error` in Payra's words (`timeout`, `http 503`, `redirect`).

```bash theme={null}
curl https://api-dashboard.payra.com/v1/events/evt_test_7Hs2kQ9mL1pXv4cR8tWzAbCd \
  -H "Authorization: Bearer sk_test_..."
```

## Resending

`POST /events/{id}/resend` with `{ "endpoint": "we_…" }` delivers the event to that endpoint again,
with the same `id` and the same body. Nothing is charged and nothing about the payment changes;
only the delivery is repeated. While an earlier delivery to that endpoint is still being retried,
the resend is `409 conflict`; a disabled endpoint is `400 parameter_invalid` naming `endpoint`.

## Managing endpoints

Up to 16 endpoints per workspace and environment (`400 webhook_endpoint_limit_reached` past that).
`POST /webhook-endpoints/{id}` changes the `url`, `enabled_events`, `description` or `status`
(`enabled` / `disabled`; a disabled endpoint keeps its secret and receives nothing, and disabling it
cancels the deliveries to it still being retried).
`DELETE /webhook-endpoints/{id}` stops it for good; its past deliveries stay readable on their
events. `GET` and the list never include the secret.

## Errors

| Code                                       | Meaning                                                                                                                               |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- |
| `parameter_invalid` on `url`               | Not `https`, or a host written as a private, loopback or link-local IP address, `localhost`, `*.localhost`, `*.local` or `*.internal` |
| `parameter_invalid` on `enabled_events[n]` | The entry at position `n` is not a type from the list above                                                                           |
| `parameter_invalid` on `enabled_events`    | `*` combined with a type, or an empty list                                                                                            |
| `webhook_endpoint_limit_reached`           | Sixteen endpoints already exist in this environment; delete one                                                                       |
| `resource_missing`                         | The endpoint or event is not one of this workspace and environment                                                                    |
| `parameter_invalid` on `endpoint`          | The resend names an endpoint that is not yours, or is disabled                                                                        |
| `conflict`                                 | A delivery of that event to that endpoint is still in flight                                                                          |
