Agent skill

Postscript Webhooks Skill

Receive and authenticate Postscript webhooks. Use when setting up a Postscript (SMS marketing for Shopify) webhook endpoint, checking the Postscript-Signature header, debugging 401s, or handling shop.incoming_message, shop.subscriber.opt_in, shop.subscriber.opt_out and shop.email_collected events.

Install this skill

npx skills add hookdeck/webhook-skills --skill 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)

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/, examples/nextjs/, 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.

EventSent When
shop.incoming_messageA shop receives an inbound text message
shop.subscriber.opt_inA subscriber opts in to promotional texts
shop.subscriber.opt_outA subscriber opts out of texts
shop.email_collectedAn email is collected through a Postscript popup
shop.testTest event (the subscription API's default event value)

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

Environment Variables

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):

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

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

Resources


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 6, 2026

View on GitHub →