# NameGender API: Webhooks

These are implementation instructions for AI coding assistants such as Claude Code, Cursor and GitHub Copilot. They are generated from the OpenAPI 3.1 contract at https://namegender.com/openapi.json, so field names, limits and error codes match the live API. The human-readable reference is https://namegender.com/docs#webhooks.

## Your task

- Add one HTTPS endpoint to this codebase that receives NameGender webhook requests, verifies them, and hands the event to the code that needs it.
- Read the signing secret from an environment variable named `NAMEGENDER_WEBHOOK_SECRET`. It starts with `whsec_` and is shown under Webhooks in the dashboard. It is not the API key.
- If an official client library exists for this language (https://namegender.com/client-libraries) and it has `webhooks.verify`, use it instead of hand-written HMAC code.
- Apply every rule under "Rules for receiving webhooks", then work through "Verify the integration".

## Events

Each event is a `POST` with a JSON body to the endpoint URL the account registered. Credit events are checked hourly and can arrive up to an hour late; they are a heads-up, not a balance feed. Dashboard: https://namegender.com/panel/webhooks.

| Event | Summary | Body |
| --- | --- | --- |
| `batch.completed` | A file job completed | `data.object` is the job, exactly as `GET /api/v1/batches/{id}` returns it. `result.url` needs your API key to download. |
| `batch.failed` | A file job failed | `data.object` is the job with `status: failed`; branch on `error.code`. A failed job is not charged. |
| `credits.low` | Credits will run out within a week | Sent once when the balance, at the average paid usage of the last 14 days, will last 7 days or less. Subscription credits and an upcoming renewal are taken into account. Checked hourly, so it can arrive up to an hour after the threshold is crossed; for an exact balance use `credits_remaining` on any API response. |
| `credits.depleted` | Credits ran out | Sent once when the balance reaches zero and requests are being refused with `402 no_credits`. Checked hourly. |
| `webhook.test` | Test event from the dashboard | Sent only by "Send test event" in the dashboard, only to that endpoint. |

## Request headers

| Header | Description |
| --- | --- |
| `NameGender-Signature` | `t=<unix seconds>,v1=<hex>`. `v1` is HMAC-SHA256 of `"<t>.<raw body>"` with the endpoint secret. During a secret rotation a second `v1` is present for 24 hours; accept the request if any `v1` matches. Reject a `t` more than 5 minutes from your clock. |
| `NameGender-Event-Id` | Same as `id` in the body, and the same on every retry. Deduplicate on it. |
| `NameGender-Delivery-Attempt` | 1 on the first try, then 2, 3… up to 8. |

## Body

| Field | Type | Description |
| --- | --- | --- |
| `id` | string | Event id; stable across retries. |
| `type` | string | New types can be added. Answer 2xx to a type you do not recognise and ignore it. |
| `created_at` | string |  |
| `api_version` | string |  |
| `data` | object |  |
| `data.object` | object |  |

`data.object` for `batch.completed` and `batch.failed` is the file job, the same object `GET https://namegender.com/api/v1/batches/{id}` returns. For `credits.low` and `credits.depleted` it is a `CreditsAlert`: `kind`, `credits_remaining`, `purchased`, `subscription`, `daily_burn`, `runway_days`, `since`.

## Signature

```text
header    = "t=1727172264,v1=5f3c…,v1=9a01…"    # one or two v1 values
signed    = t + "." + raw_body                  # raw bytes as received
expected  = hex(HMAC_SHA256(key=secret, msg=signed))
valid     = any(constant_time_equal(expected, v) for v in v1_values)
            and abs(now - t) <= 300
```

## Rules for receiving webhooks

- **Verify before anything else**, and answer 400 to a request that fails. Do not parse or act on an unverified body.
- **Use the raw body.** Read the request body as bytes before any JSON middleware. Parsing and re-serialising changes whitespace, key order and escaping, and the signature will not match. In Express use `express.raw({ type: 'application/json' })` on this route; in Flask `request.get_data()`; in Django `request.body`; in PHP `file_get_contents('php://input')`.
- **Compare in constant time** (`crypto.timingSafeEqual`, `hmac.compare_digest`, `hash_equals`), and accept the request if any `v1` matches: during a secret rotation there are two.
- **Answer 2xx within 10 seconds, then do the work.** Queue slow processing. A redirect, a timeout or any non-2xx status is treated as a failure and retried.
- **Deduplicate on `id`** (also the `NameGender-Event-Id` header). Retries carry the same id, and a request can be delivered more than once. Store processed ids and skip repeats.
- **Ignore event types you do not handle.** New types are added over time, and an endpoint with no event filter receives them. Answer 2xx and do nothing; do not throw on an unknown `type`.
- **Do not rely on order.** Read the job with `GET https://namegender.com/api/v1/batches/{id}` when the current state matters.
- **Keep the secret on the server**, out of logs and out of client code, like the API key.
- **Downloading the result needs the API key**, not the webhook secret: `result.url` is an authenticated API URL.

Answer with any 2xx within 10 seconds, then do the work. Anything else (including a redirect) is retried after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h; after 8 attempts the event is given up. Order is not guaranteed.

## Verify the integration

- Use "Send test event" in the dashboard and confirm your endpoint answers 2xx and logs a `webhook.test` event.
- Change one byte of a captured body and confirm the endpoint answers 400 and does nothing else.
- Replay a captured request with a `t` older than five minutes and confirm it is rejected.
- Send the same event twice and confirm it is processed once (deduplicated on `id`).
- Confirm the handler answers before doing slow work: a request that takes longer than 10 seconds is treated as failed and retried.
