# Webhooks — Shorty API

> Receive signed Standard Webhooks events when Shorty jobs finish, instead of polling. Event catalog, signature verification (SDK and raw Node crypto), retry schedule, failure ladder, and secret rotation.

Instead of polling, register an HTTPS endpoint and Shorty POSTs a signed event to it when a job finishes. Deliveries follow the [Standard Webhooks](https://www.standardwebhooks.com) specification, so you can verify them with any Standard-Webhooks library or the `verifyWebhookSignature` helper exported by @aishorty/sdk.

## Event catalog

Every event `data` is the SAME shape you get from [GET /v1/jobs/{id}](/docs/api-reference/getJob), so one parser works for both polling and webhooks.

| Event type | When it fires |
| --- | --- |
| `job.succeeded` | A job finished successfully. The payload `data` is the same shape as GET /v1/jobs/{id}. |
| `job.failed` | A job terminated with an error. The payload `data` is the same shape as GET /v1/jobs/{id}. |

### `job.succeeded` payload

```json
{
  "type": "job.succeeded",
  "timestamp": "2026-07-20T12:00:00.000Z",
  "data": {
    "jobId": "j1b2c3d4-0000-4000-8000-000000000000",
    "status": "success",
    "progress": 100,
    "step": null,
    "error": null,
    "output": {
      "jobType": "YOUTUBE_ARTICLE",
      "articleId": "a1b2c3d4-…"
    }
  }
}
```

### `job.failed` payload

```json
{
  "type": "job.failed",
  "timestamp": "2026-07-20T12:00:00.000Z",
  "data": {
    "jobId": "j1b2c3d4-0000-4000-8000-000000000000",
    "status": "error",
    "progress": 40,
    "step": null,
    "error": "Transcription failed: unreadable audio",
    "output": {}
  }
}
```

## Verifying signatures

Each delivery carries three headers: `webhook-id`, `webhook-timestamp`, and `webhook-signature`. The signature is `v1,` + base64 HMAC-SHA256 over `${webhook-id}.${webhook-timestamp}.${rawBody}`, keyed by the base64-decoded bytes of your endpoint secret (the part after `whsec_`).

> **Warning:** Raw-body rule: verify the EXACT bytes you received. Parsing then re-serializing the JSON (`JSON.stringify(JSON.parse(body))`) reorders keys and WILL fail verification. Capture the raw request body before any JSON parsing.

### With @aishorty/sdk

```typescript
import { verifyWebhookSignature } from '@aishorty/sdk'

// rawBody is the exact string received, before JSON.parse.
const result = verifyWebhookSignature({
    payload: rawBody,
    headers: req.headers,
    secret: process.env.SHORTY_WEBHOOK_SECRET, // whsec_...
})
if (!result.valid) {
    return res.status(400).send(result.reason)
}
const event = JSON.parse(rawBody)
```

### With Node crypto (no dependency)

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

function verify(rawBody, headers, secret) {
    const id = headers['webhook-id']
    const timestamp = headers['webhook-timestamp']
    const signatureHeader = headers['webhook-signature']
    const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64')
    const signed = `${id}.${timestamp}.${rawBody}`
    const expected = createHmac('sha256', key).update(signed, 'utf8').digest('base64')
    // Header may hold several space-separated "v1,<sig>" tokens (rotation).
    return signatureHeader.split(' ').some(token => {
        const [version, sig] = token.split(',')
        if (version !== 'v1' || !sig) return false
        const a = Buffer.from(sig, 'base64')
        const b = Buffer.from(expected, 'base64')
        return a.length === b.length && timingSafeEqual(a, b)
    })
}
```

Timestamps are rejected outside a ±5-minute tolerance (replay defense). Deliveries are sent with the `Shorty-Webhooks/1.0` user agent and a 10-second per-attempt timeout.

## Retries

A non-2xx response (or a connection error) is retried with exponential backoff and jitter: 9 attempts total (the initial attempt plus 8 retries) spread over roughly 45 hours (30s → 2m → 10m → 30m → 2h → 6h → 12h → 24h). Return any 2xx to acknowledge; a delivery that never succeeds is marked exhausted.

## Failure ladder

We email the endpoint owner as consecutive failures mount — at 5, 10, 15 failures in a row — and AUTO-DISABLE the endpoint at 20 consecutive failures. A single successful delivery resets the counter to zero. Re-enable a disabled endpoint from Settings.

## Secret rotation

Rotating an endpoint's secret opens a 24-hour dual-signing window: deliveries are signed with BOTH the new and previous secret (space-separated tokens in `webhook-signature`), so you can roll the secret on your side without dropping events. Accept a delivery if ANY token verifies.

## Delivery security

- Endpoints must be HTTPS. Requests to private, loopback, or link-local addresses are blocked (SSRF protection).
- Only response snippets are stored for the delivery log — never full response bodies.
- Manage endpoints, view the delivery log, send a test event, and rotate secrets in Settings.
