> For the complete documentation index, see [llms.txt](https://docs.auray.ai/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.auray.ai/documentation/webhooks.md).

# Webhooks

Register an endpoint and we POST to it the moment a job settles. It is the only way to hear about a video job, which refuses `?wait=` outright, and the only mode that costs nothing while you wait.

{% hint style="warning" %}
**The API is switched off on this deployment.** `API_V1_ENABLED` is unset, so every `/v1` route answers `503 api_disabled` before your key is even parsed — a valid key and a typo look identical here. Nothing on this page can be run today. It is written from the code that runs when the surface opens.
{% endhint %}

Registering and deleting need the `webhooks:write` scope. Listing needs `jobs:read`, not `webhooks:write`, because reading which endpoints exist spends nothing.

## Registering an endpoint

A new endpoint receives nothing until it answers a challenge. That is the control that stops `POST /v1/webhooks` from meaning "make Auray send signed POSTs to any address on the internet, on a schedule, from a free account".

{% stepper %}
{% step %}

### Send us the URL

{% tabs %}
{% tab title="curl" %}

```bash
curl https://api.auray.ai/v1/webhooks -X POST \
  -H "Authorization: Bearer $AURA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com/auray","products":["photo","music"]}'
```

{% endtab %}

{% tab title="Node" %}

```javascript
const res = await fetch("https://api.auray.ai/v1/webhooks", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.AURA_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    url: "https://example.com/auray",
    products: ["photo", "music"],
  }),
});

const endpoint = await res.json();
console.log(endpoint.verified, endpoint.secret);
```

{% endtab %}

{% tab title="Python" %}

```python
import os, requests

r = requests.post(
    "https://api.auray.ai/v1/webhooks",
    headers={"Authorization": f"Bearer {os.environ['AURA_API_KEY']}"},
    json={"url": "https://example.com/auray", "products": ["photo", "music"]},
)

endpoint = r.json()
print(endpoint["verified"], endpoint["secret"])
```

{% endtab %}
{% endtabs %}

An empty `products` array, or none at all, means every product — including jobs you started from the desktop rather than from a script. A value that is not a product name is `400 invalid_products`, and the response lists what is allowed.
{% endstep %}

{% step %}

### We POST a challenge to it, inline

Before the call returns. It could have been deferred, but then you would get a `201` and have to poll to find out whether your own server works, and the person registering an endpoint is almost always sitting at a terminal waiting for exactly that answer.

```json
{
  "id": "evt_challenge_5f0b7c2e-…",
  "type": "webhook.challenge",
  "created": 1787720029,
  "data": {
    "webhook_id": "5f0b7c2e-…",
    "nonce": "…",
    "how": "Answer 200 with this nonce in the body to switch this endpoint on."
  }
}
```

It is signed exactly like a real delivery, so your verification code is exercised at registration time rather than the first time a real job settles.
{% endstep %}

{% step %}

### Your endpoint echoes the nonce back

Answer `200` with the nonce in the body. We are deliberately loose about the shape and strict about the value: `{"nonce":"…"}`, `{"challenge":"…"}`, `{"data":{"nonce":"…"}}`, the bare value as plain text, or the value anywhere in an HTML page all pass. What is being proved is possession of 192 random bits, and insisting on a JSON shape on top of that would fail people who are provably the right server.

We never tell the registering caller what the nonce is. That is the entire point: registering somebody else's URL costs them one request, which they answer with a 404, and nothing further is ever sent to them.
{% endstep %}

{% step %}

### The endpoint switches on

```json
{
  "id": "5f0b7c2e-…",
  "object": "webhook",
  "url": "https://example.com/auray",
  "products": ["photo", "music"],
  "status": "active",
  "verified": true,
  "secret": "whsec_…",
  "created_at": "2026-08-27T09:12:04.881Z"
}
```

If the challenge failed you still get `201`, with `verified: false` and a `verification` object naming what went wrong — `nonce_not_echoed` comes with the first 200 characters of what you actually sent, so somebody who returned `{"ok":true}` can see why it was not enough. The endpoint exists and is `pending`; nothing is queued for it. A `4xx` here would blame your request when what is wrong is your server, and would leave you with no id to retry against.
{% endstep %}
{% endstepper %}

### What we will send to

`url` must be an absolute `https` URL of at most 2000 characters, carrying no username or password, pointing at a host reachable from the public internet. Anything else is `400 invalid_url` with a `reason`.

`https` only, and not because plain HTTP would not work. The payload names a job and an account; over HTTP every hop can read it, and the signature proves who sent it without hiding any of it. Terminate TLS yourself if you want HTTP internally.

Private addresses are refused in every form we recognise: `localhost`, `.local`, `.internal`, `.home.arpa`, the private IPv4 ranges, IPv6 loopback, unique-local and link-local, and IPv4-mapped IPv6 such as `::ffff:169.254.169.254`. The check runs again at delivery time, against the addresses your name actually resolves to, because a public name that resolves privately is the whole DNS rebinding attack and it can start doing that long after you registered.

### The signing secret

`secret` is returned once at registration and can be read again with `POST /v1/webhooks/{id}`. It is derived rather than stored — `whsec_` followed by an HMAC of the endpoint's id under a key this deployment holds — so showing it again needs none of the machinery a stored secret would need, and it cannot half-exist.

It is fixed for the life of the endpoint id. Deleting an endpoint and registering the same URL again produces a new id, and therefore a new secret.

{% hint style="info" %}
**Five endpoints per account.** Enough for one per environment and one spare, and not enough to be a fan-out. The sixth is `409 too_many_webhooks`. Endpoints killed by a `410` do not count against it. Re-registering a URL you already hold resets that endpoint rather than adding another, so the call you would try first is also the right one.
{% endhint %}

## Verifying a delivery

Every request we send carries:

```
Auray-Signature: t=1787720029,v1=1f6c…
Auray-Event: job.succeeded
Auray-Delivery: 2f1b9a0c-…
Auray-Attempt: 1
User-Agent: Auray-Webhooks/1
```

`v1` is HMAC-SHA256, hex, over `` `${timestamp}.${rawBody}` `` using your endpoint's secret. This is our own verifier, kept in step with the sender by a test that fails if the two ever disagree:

```javascript
import { createHmac, timingSafeEqual } from "node:crypto";

function verify(rawBody, header, secret) {
  if (!header) return false;

  const parts = Object.fromEntries(
    header.split(",").map((piece) => {
      const [key, ...rest] = piece.trim().split("=");
      return [key, rest.join("=")];
    }),
  );

  const { t, v1 } = parts;
  if (!t || !v1) return false;
  if (!Number.isFinite(Number(t))) return false;

  // YOUR TOLERANCE, NOT OURS. Five minutes is a reasonable default. Nothing on our
  // side enforces it — see "The timestamp is signed, not policed" below.
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;

  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(v1, "utf8");
  return a.length === b.length && timingSafeEqual(a, b);
}
```

{% hint style="danger" %}
**`rawBody` must be the bytes as they arrived.** Re-serialising a parsed object produces different bytes and the signature will never match, however correct the object looks. This is the mistake every first implementation makes, and it is the one our own test suite checks for by name.
{% endhint %}

### The timestamp is signed, not policed

`t` is inside the HMAC, so nobody can change it without breaking the signature. That is the whole of what we guarantee.

We do not reject an old `t`. There is no replay window on our side — a delivery signed an hour ago verifies exactly as well as one signed a second ago, and if somebody captures a request and sends it to you again tomorrow, the signature will still be good. The tolerance in the function above is your decision and your enforcement, and the five minutes is a suggestion rather than a number we mirror.

If you would rather not reason about clocks at all, deduplicate instead: the event `id` is `evt_<job_ref>_<status>`, one value per job per outcome, and `Auray-Delivery` is stable across every attempt of the same delivery. Either is a usable idempotency key.

## What arrives

```json
{
  "id": "evt_photo_nightly-482_succeeded",
  "type": "job.succeeded",
  "created": 1787720029,
  "data": {
    "id": "photo_nightly-482",
    "object": "job",
    "product": "photo",
    "status": "succeeded",
    "credits_charged": 4
  }
}
```

`type` is `job.` followed by the status, so `job.succeeded`, `job.failed`, `job.cancelled`.

No signed URLs and no prompt text, and both omissions are deliberate. A signed download URL lives fifteen minutes while the retry schedule runs for more than a day, so a URL in an event that finally succeeded on the fourth attempt would be a dead link you would report as a bug. The prompt is the most personal thing in a job row, and it would be crossing the internet in plaintext into a third party's log aggregator. Fetch what you need from `GET /v1/jobs/{id}` with your own key.

### How we send it

We wait five seconds for the connection and ten for the whole exchange, read at most 2 KB of your response, and never follow a redirect. Answer `2xx` and answer quickly — do the work after you have replied.

A `3xx` is not a success and is not chased. It is recorded as the status it is and retried like any other failure, so a permanent redirect from your registered URL to your real handler will burn the whole ladder and then be buried. Register the handler.

## Retries

The first attempt happens within seconds of the job settling, from the request that settled it. Everything after that is driven by a cron that sweeps every five minutes.

| Attempt | Gap after the previous failure | Elapsed since the first attempt |
| ------- | ------------------------------ | ------------------------------- |
| 1       | —                              | 0                               |
| 2       | 5 minutes                      | 5 minutes                       |
| 3       | 15 minutes                     | 20 minutes                      |
| 4       | 1 hour                         | 1 hour 20 minutes               |
| 5       | 3 hours                        | 4 hours 20 minutes              |
| 6       | 8 hours                        | 12 hours 20 minutes             |
| 7       | 24 hours                       | 36 hours 20 minutes             |

Seven attempts. Each gap carries ±20% jitter, because a platform-wide outage settles a thousand jobs into one minute and every one of their retries would otherwise land on the same second at the same host, forever.

The first gap is five minutes rather than one because the cron that drives retries runs every five minutes. A one-minute ladder would not have produced a one-minute retry; it would have produced a five-minute one while claiming otherwise. For the same reason, treat every number in that table as a floor: a retry becomes due and is then picked up on the next tick.

### When we stop

* **A `4xx` other than `408` or `429` is permanent.** A `404` or a `400` means you understood us and said no; retrying it six more times over a day and a half is noise for you and cost for us. `408` and `429` are the exceptions because both mean *not now* rather than *not ever*.
* **A `410 Gone` kills the endpoint immediately**, on the first delivery that receives it. It is the one way to tell us in so many words that the endpoint is gone rather than broken. The endpoint's status becomes `dead` and its `disabled_reason` is `endpoint_gone`.
* **Ten consecutive failures disable it.** Status `disabled`, reason `too_many_failures`. Consecutive is literal — one success clears the counter, because an endpoint that fails nine times a day and succeeds in between is having a bad network, not going away.
* **A `5xx`, a timeout, a TLS failure or a DNS failure** all take the next rung of the ladder and none of them are held against your request.

A `dead` or `disabled` endpoint stops receiving, and its queued deliveries stop moving rather than being deleted. Re-verify it and the backlog resumes from where it stopped.

Each event is created at most once per endpoint per job per outcome. Four different code paths can settle a job — an inbound service webhook, a reconcile poll, a `/v1` status read and the cron sweep — and a unique index means only the first of them creates anything, so a job settled twice still notifies you once.

## Getting an endpoint back

`POST /v1/webhooks/{id}` re-challenges the stored URL and returns the secret again. It re-challenges rather than simply flipping the status, because "it is fixed now" is a claim about a server and the only way to check a claim about a server is to ask the server.

```bash
curl https://api.auray.ai/v1/webhooks/5f0b7c2e-… -X POST \
  -H "Authorization: Bearer $AURA_API_KEY"
```

It answers `200` whether or not your server passed; `verified` in the body is the answer. The one refusal is `409 invalid_url`, which means the URL you registered no longer passes the checks — most often a name that used to resolve publicly and now resolves to a private address.

This route exists so that you do not have to delete and re-register, which would lose the endpoint's id and every delivery record attached to it — the exact history somebody debugging a failed integration wants to read.

### Checking on them

`GET /v1/webhooks` answers "why did my integration stop" without asking us:

```json
{
  "object": "list",
  "data": [{
    "object": "webhook",
    "id": "5f0b7c2e-…",
    "url": "https://example.com/auray",
    "products": ["photo", "music"],
    "status": "disabled",
    "verified_at": "2026-08-20T11:02:55.310Z",
    "consecutive_failures": 10,
    "last_success_at": "2026-08-26T22:41:09.774Z",
    "last_failure_at": "2026-08-27T08:55:31.002Z",
    "last_status_code": 502,
    "disabled_reason": "too_many_failures",
    "created_at": "2026-08-20T11:02:51.664Z"
  }]
}
```

`status` is one of `pending`, `active`, `disabled` or `dead`.

### Deleting one

`DELETE /v1/webhooks/{id}` is a real delete, unlike revoking an API key. The row goes, and its queued deliveries go with it by cascade, which is what somebody who just deleted an endpoint means. Deleting something already gone answers `200` as well — you wanted it not to exist, and it does not — with `existed: false` for a script that genuinely wants to know.

## What we do not do

**We do not enforce a replay window.** Said again because it is the assumption most likely to be wrong: the timestamp is signed and sent, and nothing on our side rejects an old one. Set your own tolerance, or deduplicate on the event id.

**We do not deliver faster than the settle.** The immediate attempt rides on the request that settled the job. When a job is settled by a cron sweep or a script instead, there is no request to ride on and the first attempt waits for the next five-minute tick.

**We pause whole destination hosts, across every account.** A host that has taken 2,000 deliveries or 100 failures in an hour is paused for an hour, and that budget is shared platform-wide rather than per account — five hundred harvested accounts all pointing at one victim would each get a full per-account budget, and they share this one instead. A pause is not held against your endpoint: the delivery is rescheduled and the failure is not counted, or one popular destination going down would disable every customer pointing at it.

**A free key can register an endpoint but cannot cause an event.** Registration spends the `write` bucket, which every plan has. Generating spends the `generate` bucket, which the free plan does not have, and the refusal is `403 plan_has_no_api_generate` rather than a `429`, so retrying never helps. A free key's endpoint will only ever hear about jobs you started from the desktop.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.auray.ai/documentation/webhooks.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
