> ## Documentation index
> Fetch the complete documentation index at: https://docs.predictefy.com/llms.txt
> Use it to discover every available page before exploring further.

# Webhooks

> Register an HTTPS endpoint and receive signed events when a job finishes or an execution changes status, instead of polling.

Predictefy can POST a signed event to your HTTPS endpoint when a background job finishes or an
execution changes status, so you do not have to poll for either.

Endpoint management is API-only today — there is no console screen for it yet.

## Events

| Event                      | Scope         | Fires when                                                                    |
| -------------------------- | ------------- | ----------------------------------------------------------------------------- |
| `ingest.run.completed`     | Platform-wide | the catalog ingest job finishes a run                                         |
| `matcher.run.completed`    | Platform-wide | the cross-match job finishes a run                                            |
| `execution.status.changed` | Your account  | a submitted execution, cancel, or refresh commits a **different** status      |

The two job events carry that job's `run_log` row — `job`, `started_at`, `finished_at`, `ok`, and a
`counts` tally. On a failed run `ok` is `false` and `counts.error` holds the message.

`execution.status.changed` carries lifecycle metadata only:

```json
{
  "executionId": "8a4fdf19-49e1-4702-9cb8-31fd9c46f7e1",
  "venue": "hyperliquid",
  "status": "filled",
  "previousStatus": "acked",
  "occurredAt": "2026-08-01T02:03:04.000Z"
}
```

It is tenant-scoped: it reaches only endpoints owned by the execution's account. Your account id is
routing context and is deliberately **not** in the payload.

It fires only on a real transition. An unchanged result, a lost concurrent update, or an
already-terminal row emits nothing — so a quiet period means nothing changed, not that a delivery was
dropped.

## Registering an endpoint

All routes are API-key authenticated and scoped to your account.

```sh
curl -s "$PREDICTEFY_API_URL/v1/webhooks" \
  -H "Authorization: Bearer pk_live_YOUR_KEY" \
  -H "content-type: application/json" \
  -d '{"url":"https://example.com/hooks/predictefy","events":["execution.status.changed"]}'
```

The response includes a **`secret` shown exactly once.** Store it immediately; it is never returned
again, and listing your endpoints redacts it.

`url` must be public **HTTPS**. Localhost, loopback, and private or link-local addresses are
rejected — IPv4, IPv6, and IPv4-mapped-IPv6 literals alike. `events` must be a non-empty subset of
the table above.

- `GET /v1/webhooks` — your endpoints, secrets redacted.
- `DELETE /v1/webhooks/{id}` — delete one, along with its queued deliveries. An id that is not yours
  returns `404`.
- `GET /v1/webhooks/{id}/deliveries?after=<cursor>&limit=<n>` — delivery rows with `id`, `event`,
  `payload`, `status`, `createdAt`, and `deliveredAt`. `limit` defaults to 50, capped at 200. Rows
  are always oldest-to-newest; pass `nextCursor` back as `after`. Both SDKs expose this as
  `client.webhooks.deliveries(id, params)`.

For local development, `predictefy webhooks listen [--endpoint <id>]` polls that route every two
seconds. Without an endpoint it creates a temporary non-routable one and deletes it on exit.
`--forward http://localhost:PORT/path` re-POSTs each payload locally.

## What a delivery looks like

Each delivery is a `POST` to your URL with these headers:

| Header                   | Value                                        |
| ------------------------ | -------------------------------------------- |
| `Content-Type`           | `application/json`                           |
| `X-Predictefy-Event`     | the event name                               |
| `X-Predictefy-Timestamp` | Unix seconds when the delivery was signed    |
| `X-Predictefy-Signature` | hex HMAC-SHA256 — see below                  |

Redirects are **not followed**, and each attempt times out after 10 seconds.

## Verifying the signature

The signature is a hex HMAC-SHA256 over `` `${timestamp}.${rawBody}` `` keyed by your endpoint
secret. Recompute it over the **exact bytes you received** — not a re-serialized object — and compare
in constant time:

```ts
import { createHmac, timingSafeEqual } from 'node:crypto';

function verify(rawBody: string, headers: Record<string, string>, secret: string): boolean {
  const timestamp = headers['x-predictefy-timestamp'];
  const signature = headers['x-predictefy-signature'];
  if (!timestamp || !signature) return false;
  const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
  const a = Buffer.from(signature, 'hex');
  const b = Buffer.from(expected, 'hex');
  return a.length === b.length && timingSafeEqual(a, b);
}
```

Optionally reject deliveries whose `X-Predictefy-Timestamp` is far from your clock, to blunt replay
attacks.

## Retries

Respond `2xx` to acknowledge. Any non-`2xx`, network error, or timeout is retried with exponential
backoff, then abandoned:

| Attempt                 | Retry after                            |
| ----------------------- | -------------------------------------- |
| 1 → 2                   | 1 minute                               |
| 2 → 3                   | 5 minutes                              |
| 3 → 4                   | 30 minutes                             |
| 4 → 5                   | 2 hours                                |
| after 5 failed attempts | marked **failed** (no further retries) |

**Make your handler idempotent.** For job events, dedupe on the payload's `run_log` identity; for
execution events, on `executionId`, `status`, and `occurredAt`.

## Availability

Delivery requires `EXEC_WEBHOOKS_ENABLE=true` on the execution service, which defaults to `false`.
Endpoint management is gated by `READS_ENABLE_WEBHOOK_ROUTES`. Register an endpoint and check
`GET /v1/webhooks/{id}/deliveries` to confirm both are live on the deployment you are calling.
