# Webhooks



The platform makes a `POST` to your URL when something happens. Every send goes through the same delivery engine: the message is stored, each destination gets a delivery with its own retries, and every HTTP call is logged. There are three origins:

| Origin                                         | Where it is configured                                             | Cost                                       |
| ---------------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------ |
| **Account events** (e.g. `qr.dynamic.scanned`) | **Webhooks** in the [dashboard](https://dashboard.respawntech.dev) | Free                                       |
| **Job callbacks** (e.g. PDF conversion)        | `webhook_url` in each request                                      | Free                                       |
| **Webhook Relay** (your own events)            | The [Webhook Relay](/en/ferramentas/webhook-relay) API             | Per delivery beyond the monthly free quota |

## Account events

Register an endpoint under **Webhooks** in the [dashboard](https://dashboard.respawntech.dev) and choose the events. Each endpoint has its own `secret`, shown on creation and afterwards through the reveal-secret option.

These endpoints belong to [Webhook Relay](/en/ferramentas/webhook-relay#projetos): an endpoint of any project that subscribes to an account event receives it, for free. The old account webhooks API is deprecated; see [Legacy account API](/en/ferramentas/webhook-relay#api-legada).

<WebhookEventsTable />

## Request sent

```http
POST <endpoint url>
Content-Type: application/json
User-Agent: RespawnTech-Webhook/2.0
webhook-id: msg_2Vf8tK1pQ9zX
webhook-timestamp: 1790338000
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
X-Webhook-Signature: sha256=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
```

| Header                | Content                                                                                         |
| --------------------- | ----------------------------------------------------------------------------------------------- |
| `webhook-id`          | Message identifier. It is the **same across every retry**: use it to drop duplicates.           |
| `webhook-timestamp`   | Time of this attempt, in Unix seconds.                                                          |
| `webhook-signature`   | `v1,<base64>`, one signature per active secret, space separated (two during a secret rotation). |
| `X-Webhook-Signature` | Legacy `sha256=<hex>` signature, without timestamp. Kept for older receivers.                   |

The headers follow the [Standard Webhooks](https://www.standardwebhooks.com) scheme, so any library for that standard verifies the request. Webhook Relay endpoints may carry extra headers (`custom_headers`).

### Body

Account events and Webhook Relay messages use the same envelope:

```json
{
  "event_id": "550e8400-e29b-41d4-a716-446655440000",
  "type": "qr.dynamic.scanned",
  "timestamp": 1790338000,
  "payload": {
    "resource_id": "…",
    "slug": "promo-verao",
    "ip": "anonymous_hash",
    "user_agent": "Mozilla/5.0 …",
    "referer": "https://…"
  }
}
```

The shape of `payload` depends on `type`. Job callbacks keep their own body (see [Job callback](#pdf-callback)), and messages received from providers through Webhook Relay arrive with the provider's original body, unchanged.

## Verifying the signature

The signed content is `webhook-id + "." + webhook-timestamp + "." + raw body`, HMAC-SHA256, base64-encoded. To verify:

1. Read the **raw body**, before any JSON parsing (reformatting the JSON changes the signature).
2. Reject timestamps more than <Limit name="webhookSignatureTolerance" /> away from your clock (replay protection).
3. Compute the signature and compare it, **in constant time**, with each item of `webhook-signature`. One match is enough.

With a Standard Webhooks library, use `standard_webhooks_secret` (the `whsec_…` form). Without a library, the HMAC key is the bytes of the raw `secret`.

<Tabs groupId="code-language" persist items={['JavaScript', 'Python', 'Go']}>
  <Tab value="JavaScript">
    ```js
    // npm i standardwebhooks
    import express from 'express';
    import { Webhook } from 'standardwebhooks';

    const wh = new Webhook(process.env.WEBHOOK_SECRET); // whsec_…
    const app = express();

    app.post('/hook', express.raw({ type: '*/*' }), (req, res) => {
      let event;
      try {
        event = wh.verify(req.body, req.headers); // throws when invalid
      } catch {
        return res.sendStatus(401);
      }
      // process `event` asynchronously and answer fast
      res.sendStatus(204);
    });
    ```
  </Tab>

  <Tab value="Python">
    ```python
    # pip install standardwebhooks
    import os
    from flask import Flask, request
    from standardwebhooks.webhooks import Webhook

    wh = Webhook(os.environ["WEBHOOK_SECRET"])  # whsec_…
    app = Flask(__name__)

    @app.post("/hook")
    def hook():
        try:
            event = wh.verify(request.get_data(), dict(request.headers))
        except Exception:
            return "", 401
        # process `event` asynchronously and answer fast
        return "", 204
    ```
  </Tab>

  <Tab value="Go">
    ```go
    // Without a library: secret is the raw value (not the whsec_ form).
    func verify(secret string, r *http.Request, body []byte) bool {
    	id, ts := r.Header.Get("webhook-id"), r.Header.Get("webhook-timestamp")
    	t, err := strconv.ParseInt(ts, 10, 64)
    	if err != nil || math.Abs(time.Since(time.Unix(t, 0)).Seconds()) > 300 {
    		return false
    	}
    	mac := hmac.New(sha256.New, []byte(secret))
    	mac.Write([]byte(id + "." + ts + "."))
    	mac.Write(body)
    	want := "v1," + base64.StdEncoding.EncodeToString(mac.Sum(nil))
    	for _, sig := range strings.Fields(r.Header.Get("webhook-signature")) {
    		if hmac.Equal([]byte(sig), []byte(want)) {
    			return true
    		}
    	}
    	return false
    }
    ```
  </Tab>
</Tabs>

<Callout title="Older receivers">
  Receivers that already verify `X-Webhook-Signature` keep working: it is `sha256=` + the hex HMAC-SHA256 of the raw body, keyed with the raw `secret`. This header has no timestamp; prefer `webhook-signature` in new integrations.
</Callout>

## Secrets and rotation

| Destination                  | Where to get the secret                                                                                                                    |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| Account event endpoint       | On creation, or through the reveal-secret option under **Webhooks** in the dashboard.                                                      |
| Webhook Relay endpoint       | On creation, or with [Reveal secret](/en/ferramentas/webhook-relay#webhooks-endpoints-secret).                                             |
| Job callback (`webhook_url`) | The account signing secret, one for every callback: `GET /v1/account/webhooks/signing-secret` (account route, with the dashboard session). |

Every secret comes in two forms: `secret` (raw) and `standard_webhooks_secret` (`whsec_` + base64 of the same bytes). Both verify the same signatures.

When you [rotate an endpoint's secret](/en/ferramentas/webhook-relay#webhooks-endpoints-rotate_secret), the old one keeps signing for <Limit name="webhookSecretOverlap" />: during that window `webhook-signature` carries both signatures, so you can update the receiver without losing events.

## Delivery and retries

Only a **2xx** response counts as success. Redirects (3xx) are not followed, and 3xx, 4xx, 5xx, timeouts and network errors are retried on this schedule:

| Attempt | Wait before it |
| ------- | -------------- |
| 1       | —              |
| 2       | 5 seconds      |
| 3       | 5 minutes      |
| 4       | 30 minutes     |
| 5       | 2 hours        |
| 6       | 5 hours        |
| 7 and 8 | 10 hours       |

About 27 hours in total, with ±10% jitter. Special cases:

* **`429` and `503`** with `Retry-After`: the next attempt honors the header when it asks for longer (up to 10 hours).
* **`410 Gone`**: the delivery stops and the endpoint is disabled.
* After the last attempt, the delivery is `exhausted`. With Webhook Relay you can [retry the message](/en/ferramentas/webhook-relay#webhooks-messages-retry) or [recover an endpoint's failures](/en/ferramentas/webhook-relay#webhooks-endpoints-recover).

Good practices in the receiver:

* Answer **2xx** as fast as possible and process the event asynchronously.
* **Drop duplicates by `webhook-id`**: a delivery can arrive more than once (e.g. your server processed it but the response was lost).
* Don't rely on arrival order; use the body's `timestamp` when order matters.

### Automatic disabling

An endpoint that keeps failing for <Limit name="webhookDisableAfter" /> is disabled automatically (`is_active: false`, with `disabled_reason`), as is one that answers `410`. After fixing the receiver, re-enable the endpoint (in the dashboard or with `is_active: true` in Webhook Relay); this clears the failing streak.

The history of messages, deliveries and attempts is kept for <Limit name="webhookLogRetention" />.

## Job callback

In `POST /convert` or `POST /batch` of [PDF Convert](/en/ferramentas/pdf-convert), send `webhook_url` in the body. When the job finishes, the API makes a `POST` to that URL:

```json
{
  "job_id": "job_abc123",
  "success": true,
  "result_url": "https://…",
  "metadata": { "conversion_id": "…" }
}
```

On failure, `success` is `false` and the `error` field holds the message. The callback has the same headers, signature and retries as the other webhooks, signed with the **account signing secret** (see [Secrets and rotation](#secrets)). `webhook_url` must be a public `http` or `https` URL; otherwise the request is refused with `webhook.invalid_url`.

## Your own events

To send webhooks from **your** application to your customers, with the same signature, retries and log, or to receive webhooks from Stripe, GitHub and others and fan them out, use [Webhook Relay](/en/ferramentas/webhook-relay).
