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-Signatureheader? - Is
Postscript-Signaturean HMAC? (No: it is the account signing token, sent as plain text) - How do I handle
shop.incoming_messageorshop.subscriber.opt_inevents? - 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 gets200. - 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.
| 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
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
- references/overview.md - What Postscript webhooks are, events and payload shape
- references/setup.md - Get the token and register subscriptions
- references/verification.md - Token check details, gotchas, debugging 401s
- examples/ - Runnable Express, Next.js and FastAPI handlers with tests