PodcastsToText
Webhooks

Security and delivery

Verify the signature in constant time, plus retries, the delivery log, and replaying one.

Every delivery is signed, and delivery is at-least-once with bounded retries. Verify before you act on a payload: an unverified endpoint is an unauthenticated POST endpoint anyone who learns the URL can drive.

The signature

X-PTT-Signature
X-PTT-Signature: t=1754774400,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

t is the Unix timestamp of this attempt (retries are re-signed); v1 is HMAC-SHA256 of ` ${t}.${rawBody} ` with your endpoint secret, hex encoded. Read the versions you know and ignore the rest.

  1. 1
    Read the RAW body
    Before any JSON parsing. The signature covers the exact bytes we sent, and re-serialising a parsed object reorders keys. This is the most common mistake.
  2. 2
    Bound the timestamp
    Reject anything older than about five minutes, or a captured delivery can be replayed forever.
  3. 3
    Compare in constant time
    A byte-by-byte == leaks, through timing, how much of a forged signature was correct.
Node.js
import crypto from 'node:crypto';

export function verify({ secret, rawBody, header, toleranceSeconds = 300 }) {
  const parts = Object.fromEntries(
    String(header).split(',').map((p) => p.split('=').map((s) => s.trim())),
  );
  const t = Number(parts.t);
  if (!Number.isFinite(t) || !parts.v1) return false;

  // Replay window.
  if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSeconds) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${t}.${rawBody}`, 'utf8')
    .digest('hex');

  // timingSafeEqual throws on a length mismatch, so check length first.
  if (parts.v1.length !== expected.length) return false;
  return crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
}

// Express: get the raw body, not the parsed one.
app.post('/hooks/ptt', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verify({ secret: process.env.PTT_WEBHOOK_SECRET,
                rawBody: req.body.toString('utf8'),
                header: req.get('X-PTT-Signature') })) {
    return res.status(400).send('bad signature');
  }
  res.sendStatus(200);              // acknowledge first
  queue.add(JSON.parse(req.body));  // work afterwards
});
Python
import hmac, hashlib, time

def verify(secret: str, raw_body: bytes, header: str, tolerance: int = 300) -> bool:
    parts = dict(p.strip().split("=", 1) for p in header.split(","))
    try:
        t = int(parts["t"]); given = parts["v1"]
    except (KeyError, ValueError):
        return False

    if abs(int(time.time()) - t) > tolerance:
        return False

    expected = hmac.new(
        secret.encode(),
        f"{t}.".encode() + raw_body,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(given, expected)

Endpoints must be public HTTPS: private ranges, loopback and link-local addresses (the cloud metadata endpoint at 169.254.169.254 included) are refused, and re-checked at delivery time, since a hostname that resolved publicly last week can point somewhere private today. Redirects are not followed. Secrets are not rotatable in place: register a second endpoint, accept either secret, confirm, then delete the old one.

Retries

AttemptSent afterElapsed
1immediately0
21 minute1m
35 minutes6m
430 minutes36m
52 hours2h 36m
66 hours8h 36m
712 hours20h 36m

Seven attempts over roughly 20.5 hours, front-loaded: most failures are a deploy and clear within minutes, and the long tail survives an overnight incident. The request timeout is 10 seconds. Ordering is not guaranteed: a retried event can arrive after a later one, so use created_at rather than arrival order.

Your responseTreated asRetried
2xxDelivered—
410 GoneRetired: the endpoint is disabledNo
408, 429TemporaryYes
other 4xxRejected: you understood it and refusedNo
5xxServer errorYes
timeout, DNS failure, connection refusedUnreachableYes

The delivery log

GET/api/v1/webhooks/{id}/deliveriesscope: shows:read
FieldTypeRequiredDescription
statusstringNoOne of queued, delivering, delivered, failed.
eventstringNoOne of the three event names.
limitnumberNoDefault 20, maximum 100.
cursorstringNoThe next_cursor from a previous page.
GET …/deliveries?status=failed
{
  "data": [
    {
      "id": "8c1f2d3e-…",
      "endpoint_id": "b21c…",
      "event": "transcription.completed",
      "status": "failed",
      "attempts": 7,
      "max_attempts": 7,
      "response_status": 500,
      "error": "Endpoint returned 500",
      "created_at": "2026-08-18T09:12:44.117Z",
      "delivered_at": null,
      "next_attempt_at": null
    }
  ],
  "next_cursor": null,
  "endpoint": {
    "id": "b21c…",
    "url": "https://example.com/hooks/ptt",
    "disabled": false,
    "last_error": "Endpoint returned 500",
    "last_success_at": "2026-08-17T22:04:10.000Z"
  }
}

No payloads or response bodies in the list: it is for finding the interesting row. next_attempt_at is set only while a delivery is queued.

GET/api/v1/webhooks/{id}/deliveries/{deliveryId}scope: shows:read

One delivery in full adds payload (exactly what was sent), response_body, truncated to 2000 characters, and replay_blocked_reason, which is null when a replay would be accepted and a sentence when it would not, so you can decide without POSTing to find out.

Replaying one

POST/api/v1/webhooks/{id}/deliveries/{deliveryId}/retryscope: shows:write

Returns 202 once queued; the sweep runs every minute. The attempt count is reset, so the delivery gets the full ladder again, and the delivery id does not change: a receiver deduplicating on id treats it as the same event, which is the point. It needs shows:write because it causes an outbound request under your account.

SituationResponseWhy
Delivery is queued400It will be attempted anyway. Replaying would send it twice.
Delivery is delivering400In flight right now.
Endpoint is disabled400Re-enable first: PATCH /api/v1/webhooks/{id} with {"disabled": false}. An endpoint that returned 410 Gone retired itself, and we honour that.
Claimed mid-request200, queued: falseThe sweep picked it up between the check and the write. Not an error.

Debugging order. GET /api/v1/webhooks first: a recent last_success_at means the problem is one event, not the endpoint. Then list failures: 401/403 is your own auth in front of the receiver, 404 a wrong path, 5xx your handler throwing, and a null response_status with a timeout means you are working before acknowledging.