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

# Webhooks

Publica.la sends a signed JSON `POST` request to each of your endpoints when an event it subscribes to happens in your store.

They replace the deprecated [legacy sale webhook](./legacy-sale-webhook.md). Both can run at the same time; its page lists what changes.

## Events

| Event                             | Sent when                                                |
| --------------------------------- | -------------------------------------------------------- |
| [`order.paid`](./order-paid.md)   | The first approved sale payment of an order is recorded. |
| [`charge.paid`](./charge-paid.md) | A payment gateway charge is approved, renewals included. |

## Endpoints

Store administrators manage endpoints in the dashboard, in **Settings > Webhooks** (`/dashboard/settings#webhooks`). Plan administrators cannot.

| Property    | Description                                                      |
| ----------- | ---------------------------------------------------------------- |
| URL         | Where events are sent. See the rules below.                      |
| Name        | Optional label.                                                  |
| Events      | The event types the endpoint receives.                           |
| Enabled     | When cleared, the endpoint is paused.                            |
| Signing key | 64 hexadecimal characters. Shown once, on creation and rotation. |

A store can have up to 10 endpoints.

The URL must:

- Use `http` or `https`. With `http`, events travel unencrypted.
- Use port 80 or 443 if it sets a port.
- Resolve only to public IP addresses. This is checked again on every delivery.

Redirects are not followed. Requests come from the addresses in [Platform IP Addresses](../platform-ip-addresses.md).

| Action             | Effect                                                                                                            |
| ------------------ | ----------------------------------------------------------------------------------------------------------------- |
| Edit               | Changing the URL resets the failure count and re-enables a disabled endpoint.                                     |
| Pause              | Nothing is delivered. Events that happen while paused are dropped, not queued.                                    |
| Rotate signing key | The previous key stops working immediately.                                                                       |
| Send test          | Sends a [test delivery](#test-deliveries). Up to 5 per endpoint per minute.                                       |
| Re-enable          | Shown on an endpoint Publica.la [disabled](#automatic-disabling), with the last error. Also resumes a paused one. |
| Delete             | Immediate and permanent.                                                                                          |

**Recent deliveries** lists the last 50 delivery attempts, kept for 30 days, with status `Delivered` (a `2xx`), `Failed` (retried while attempts remain) or `Skipped` (not sent: the endpoint was paused, disabled or deleted, or the event had already reached it).

## Request

### Headers

| Header                  | Value                                                                                                 |
| ----------------------- | ----------------------------------------------------------------------------------------------------- |
| `Content-Type`          | `application/json`                                                                                    |
| `User-Agent`            | `publica.la webhooks (+https://publica.la)`                                                           |
| `X-Pla-Signature`       | `t=<unix timestamp>,v1=<hex HMAC-SHA256>`. See [Signature](#signature).                               |
| `X-Pla-Event`           | Event type, for example `order.paid`. Equal to `type` in the body.                                    |
| `X-Pla-Delivery`        | Delivery id, `evt_` followed by a UUID. Equal to `id` in the body.                                    |
| `X-Pla-Idempotency-Key` | Key of the fact the event announces, for example `order.paid:order:1001`. Equal to `idempotency_key`. |

### Envelope

```json
{
  "id": "evt_7c1d4e92-3b6a-4f0e-8a15-d2e9b0c47f36",
  "type": "order.paid",
  "created_at": "2026-01-15T14:30:06Z",
  "idempotency_key": "order.paid:order:1001",
  "tenant": {
    "id": "42",
    "slug": "yourstore"
  },
  "data": {}
}
```

| Field             | Type   | Description                                                                          |
| ----------------- | ------ | ------------------------------------------------------------------------------------ |
| `id`              | string | Delivery id. Unique per endpoint: two endpoints get different ids for one event.     |
| `type`            | string | Event type.                                                                          |
| `created_at`      | string | ISO 8601 UTC time the delivery was queued.                                           |
| `idempotency_key` | string | Stable key of the fact the event announces. Same for every endpoint and every retry. |
| `tenant`          | object | Your store: `id` (string) and `slug` (string).                                       |
| `data`            | object | Event payload, described on each event's page.                                       |

The body, `id` and `created_at` do not change across retries.

### Payload conventions

- Ids are strings.
- Money amounts are integers in the currency's minor unit, in fields ending in `_in_cents`: `1349` is 13.49 USD, `11900` is 11,900 CLP.
- Dates are ISO 8601 strings in UTC.
- Every key is always present. A missing value is `null`.
- The payload is not versioned. New keys and event types can be added at any time. Renaming, removing or retyping a key ships as a new event type.

## Signature

`X-Pla-Signature` is `t=<timestamp>,v1=<signature>`:

- `t`: Unix timestamp in seconds when the request was signed. Each retry is signed again with a new `t`.
- `v1`: lowercase hex HMAC-SHA256 of the string `<t>.<raw body>`, keyed with the signing key as is (not hex-decoded).

To verify a request:

1. Split the header on `,`, and each part on the first `=`.
2. Reject it if `t` is more than 300 seconds away from your current time, in either direction.
3. Compute the HMAC over the raw body bytes as received, not over re-encoded JSON.
4. Compare it with `v1` in constant time.

Test vector:

| Input       | Value                                                                              |
| ----------- | ---------------------------------------------------------------------------------- |
| Signing key | `0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef`                 |
| Body        | `{"id":"evt_00000000-0000-4000-8000-000000000000","type":"order.paid"}`            |
| Header      | `t=1767225600,v1=cfdb027237491eed8330ae6e8aa4a543fa01895ab694b18d1095f4fb22cf23bb` |

## Delivery

| Property   | Value                                                                                                                                           |
| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| Success    | Any `2xx` status. The response body is ignored.                                                                                                 |
| Failure    | Any other status, `3xx` included; no response within 10 seconds; a connection error; a URL that no longer resolves only to public IP addresses. |
| Attempts   | Up to 12 per delivery, over about 70 hours.                                                                                                     |
| Order      | Not guaranteed, across events or endpoints.                                                                                                     |
| Duplicates | Possible: delivery is at least once.                                                                                                            |

### Retries

Wait after each failed attempt:

| After attempt | Wait       |
| ------------- | ---------- |
| 1             | 1 minute   |
| 2             | 5 minutes  |
| 3             | 15 minutes |
| 4             | 1 hour     |
| 5             | 3 hours    |
| 6             | 6 hours    |
| 7 to 11       | 12 hours   |

A delivery whose 12 attempts fail is exhausted and not sent again.

### Duplicates

The same event can arrive more than once, for example when your endpoint processed a request but its response took longer than 10 seconds. Deduplicate on `X-Pla-Idempotency-Key`, not on `X-Pla-Delivery`, which differs per endpoint.

### Automatic disabling

Publica.la disables an endpoint when both are true:

- Its last 5 deliveries were exhausted.
- It has had no successful delivery in the last 72 hours, or since it was created if that was less than 72 hours ago.

A successful delivery resets the count. A disabled endpoint receives nothing: new events are dropped and pending retries are cancelled. No notice is sent; the dashboard shows it as **Disabled** until it is re-enabled.

### Test deliveries

**Send test** sends the example payload of the first event in the [Events](#events) table the endpoint receives (or of the first event in the table if it receives none), with real headers and signature. Its ids are fictitious. Unlike a real delivery:

- Its idempotency key is `test:` followed by a UUID.
- It is attempted once.
- It is sent even when the endpoint is paused or disabled.
- It does not count towards automatic disabling.

## Related Documentation

- [`order.paid`](./order-paid.md): The order paid event.
- [`charge.paid`](./charge-paid.md): The charge paid event.
- [Legacy sale webhook](./legacy-sale-webhook.md): The deprecated JWT sale webhook, and how to migrate from it.
