# Postscript Webhooks

Postscript sends an HTTPS `POST` with a JSON body when something happens in a shop: a subscriber
texts the shop, opts in or out, or an email is collected. **Despite its name, `Postscript-Signature`
is not an HMAC of the body.** It carries the account's fixed webhook signing token, the same value
returned by `GET /api/v2/webhooks/token` (the API docs describe it as a fixed MD5 hash that "can be
compared to the Postscript-Signature request header"). There is no signature over the payload.

Your endpoint authenticates the sender by comparing the whole header value with the token you
stored.

## When to Use This Skill

* How do I receive Postscript webhooks?
* How do I verify the `Postscript-Signature` header?
* Is `Postscript-Signature` an HMAC? (No: it is the account signing token, sent as plain text)
* How do I handle `shop.incoming_message` or `shop.subscriber.opt_in` events?
* Why does my Postscript webhook endpoint return 401?

## Verification (core)

```javascript
const crypto = require('crypto');

// Postscript-Signature is the account signing token itself, not an HMAC of the body.
function verifyPostscriptRequest(headers, token = process.env.POSTSCRIPT_WEBHOOK_TOKEN) {
  if (!token) return false;                          // never accept when unconfigured
  const received = headers['postscript-signature'];  // whole header value
  if (!received) return false;
  const a = Buffer.from(received);
  const b = Buffer.from(token);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

```

* Header: `Postscript-Signature: <account signing token>` (case-insensitive name, whole value compared).
* Get the token with `GET https://api.postscript.io/api/v2/webhooks/token`.
* Missing header or mismatch: return `401`. A valid request gets `200`.
* Read the body raw (`express.raw({ type: '*/*' })`) and check the token before parsing it.
* **Security limits:** a static shared credential authenticates the sender, not the body, and gives
  no replay protection (no signature, no timestamp). Use HTTPS only. The docs call the token fixed
  and document no way to rotate it, so guard it carefully. Make the handler idempotent, but not on
  `webhook_id` (see Retries).

> **For complete handlers with tests**, see [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/postscript-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/postscript-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/postscript-webhooks/examples/fastapi/).

## Event Types

The payload has `event`, `resource_type`, `resource_id`, `webhook_id`, `event_time` and
`event_data`. Subscriptions are created with `POST /api/v2/webhooks`, one event each; the request
can also carry an optional `headers` object ("Custom key:value pairs to be sent as headers in every
callback"), which you can use for an extra secret of your own.

| Event | Sent When |
| --- | --- |
| `shop.incoming_message` | A shop receives an inbound text message |
| `shop.subscriber.opt_in` | A subscriber opts in to promotional texts |
| `shop.subscriber.opt_out` | A subscriber opts out of texts |
| `shop.email_collected` | An email is collected through a Postscript popup |
| `shop.test` | Test event (the subscription API's default `event` value) |

Postscript may add event types at any time, so acknowledge unknown events with `200`.

## Environment Variables

```bash
POSTSCRIPT_WEBHOOK_TOKEN=your-webhook-signing-token   # from GET /api/v2/webhooks/token

```

## Hookdeck Setup Note

When you add a Postscript source in Hookdeck (source type `POSTSCRIPT`), paste the token returned by
`GET /api/v2/webhooks/token`. Hookdeck compares the whole `Postscript-Signature` value to it.

## Retries

If Postscript does not get a `200` quickly, it retries every 5 minutes for up to 1 hour. Respond
`200` before doing heavy work. Payloads have no documented event id. `webhook_id` appears to be the subscription's id (same UUID form as the subscription `id`), so it repeats across events: don't dedupe on it. Use `event_data.id` where the event has one (e.g. `im_...` on `shop.incoming_message`), or `event` + `resource_id` + `event_time`.

## Local Development

Postscript can't reach `localhost`. Tunnel with the Hookdeck CLI (no account required):

```bash
npx hookdeck-cli listen 3000 postscript --path /webhooks/postscript

```

Use the printed URL as `callback_url` when creating the webhook subscription.

## Resources

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/postscript-webhooks/references/overview.md) - What Postscript webhooks are, events and payload shape
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/postscript-webhooks/references/setup.md) - Get the token and register subscriptions
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/postscript-webhooks/references/verification.md) - Token check details, gotchas, debugging 401s
* [examples/](https://github.com/hookdeck/webhook-skills/tree/main/skills/postscript-webhooks/examples/) - Runnable Express, Next.js and FastAPI handlers with tests