Gareth Wilson Gareth Wilson

Guide to Postscript Webhooks: Features and Best Practices

Published


Postscript webhooks let your application hear about a shop's SMS activity as it happens. Postscript runs SMS marketing for Shopify stores, and its webhooks report inbound texts, opt-ins, opt-outs and emails collected through Postscript popups, so you can answer customer messages, sync consent to other systems, or start a flow when someone signs up.

This guide covers the events and payload, how to subscribe and authenticate, what the Postscript-Signature header really is, and how to receive Postscript webhooks reliably.

What are Postscript webhooks?

Postscript sends webhooks as HTTPS POST requests with a JSON body, to a callback URL you register through the API. Each subscription is for one event.

Despite its name, the Postscript-Signature header is not a signature computed over the request. It carries a fixed token for your account, which you read once from the API. Postscript's docs say to compare the header with that token: if they match, the request came from Postscript. Nothing in the header depends on the body, so this works like a shared secret, not like an HMAC.

Postscript webhook features

FeatureDetails
ConfigurationAPI: POST https://api.postscript.io/api/v2/webhooks
Events4 today; Postscript may add more
Events per subscriptionOne
Method and formatHTTPS POST, JSON
AuthenticationPostscript-Signature header with a fixed per-account token
TokenGET /api/v2/webhooks/token
Body signature and timestampNone
RetriesEvery 5 minutes for up to 1 hour
Response bodyIgnored
DuplicatesPossible; handlers must be idempotent
Test endpointPOST /api/v2/webhooks/test, mocked data

Postscript webhook events

EventSent when
shop.incoming_messageA shop receives an inbound text message
shop.subscriber.opt_inA subscriber is opted in to promotional texts, most often by replying to a confirmation text
shop.subscriber.opt_outA subscriber is opted out of a shop's texts
shop.email_collectedAn email is collected through a Postscript popup

Two caveats from Postscript's event reference. shop.incoming_message does not include every message: keywords and subscriber lifecycle messages, among others, may not come through. And Postscript may add event types at any time, so your code should not assume these four are the only ones.

The payload

Every event has the same envelope, with the event-specific data in event_data. This is Postscript's example of a shop.incoming_message event:

{
  "webhook_id": "89066f0a-eb36-4ed8-b98c-606e6dcac715",
  "resource_type": "shop",
  "resource_id": "shop_1234567abcd",
  "event_time": "2021-11-04T21:52:41.025455",
  "event": "shop.incoming_message",
  "event_data": {
    "id": "im_1b2a005a386281c9",
    "subscriber_id": "s_1234567abcd",
    "shop_id": "shop_1234567abcd",
    "is_opt_out": false,
    "shopify_customer_id": null,
    "from_number": "+15555555555",
    "created_at": "2021-11-04T21:52:41.025455",
    "body": "Hello, world"
  }
}

Route on event. The event names follow a resource.event pattern, with nested subresources as resource.subresource.event, so resource_type and resource_id tell you which shop the event is about. Postscript publishes an example body for shop.incoming_message only, so capture a test delivery of the others before relying on their event_data fields.

Inspect Postscript webhooks as they arrive. Register a Hookdeck Inspect URL as the callback_url and call the test endpoint to see exactly what Postscript sends, with no account or setup required.

Setting up Postscript webhooks

Subscriptions are created through the API, authenticated with the shop's API token in the Authorization header. Partners send the shop's API token in X-Postscript-Shop-Token instead. The create endpoint also accepts an optional headers object of custom headers to send with every callback, and shop.test as an event.

  1. Create a subscription for each event you need:
curl -X POST https://api.postscript.io/api/v2/webhooks \
  -H "Authorization: Bearer $POSTSCRIPT_API_TOKEN" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{
    "callback_url": "https://example.com/webhooks/postscript",
    "event": "shop.incoming_message"
  }'
  1. Read the account's webhook signing token and store it as a secret:
curl https://api.postscript.io/api/v2/webhooks/token \
  -H "Authorization: Bearer $POSTSCRIPT_API_TOKEN"
# {"token": "..."}
  1. Send a test event. The endpoint returns 202 Accepted, processes the request asynchronously, and delivers mocked data to the callback URL. The event defaults to shop.test, and you can also pass one of the four real event names.
curl -X POST https://api.postscript.io/api/v2/webhooks/test \
  -H "Authorization: Bearer $POSTSCRIPT_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"event": "shop.test"}'

You can also send a text to the shop's number to trigger a real shop.incoming_message.

To route through Hookdeck, create a Hookdeck source with the Postscript type and use its URL as the callback_url. The source has one field, Signing Token: paste the token returned by GET /api/v2/webhooks/token. Hookdeck compares it with the Postscript-Signature header on each request. The source accepts POST, which is the method Postscript uses.

Securing Postscript webhooks

Compare the Postscript-Signature header with your stored token, in constant time. Do not try to compute an HMAC over the body; there is nothing to compute.

const crypto = require('crypto');
const express = require('express');

// The value from GET /api/v2/webhooks/token.
const TOKEN = process.env.POSTSCRIPT_SIGNING_TOKEN;

function safeEqual(received, expected) {
  const a = Buffer.from(received, 'utf8');
  const b = Buffer.from(expected, 'utf8');
  // timingSafeEqual throws on unequal lengths, so check first.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();

app.post('/webhooks/postscript', express.raw({ type: '*/*' }), (req, res) => {
  const received = req.get('postscript-signature');
  if (!TOKEN || typeof received !== 'string' || !safeEqual(received, TOKEN)) {
    return res.sendStatus(401);
  }

  let event;
  try {
    event = JSON.parse(req.body.toString('utf8'));
  } catch {
    return res.sendStatus(400);
  }

  // Queue the event and answer before doing real work.
  res.sendStatus(200);
});

The same check in Python:

import hmac
import os

TOKEN = os.environ.get("POSTSCRIPT_SIGNING_TOKEN", "")


def verify_postscript_request(headers) -> bool:
    if not TOKEN:
        return False  # never accept when unconfigured
    received = headers.get("Postscript-Signature")  # use a case-insensitive headers object
    if not received:
        return False
    # Compare bytes: compare_digest raises TypeError on a non-ASCII str.
    return hmac.compare_digest(received.encode("utf-8"), TOKEN.encode("utf-8"))

Because the token is fixed, it authenticates the sender but not the body. It cannot detect a modified request, and with no timestamp a captured request can be replayed. The token is the same on every delivery for the account, and Postscript documents no way to rotate it, so treat it like a password: use an HTTPS callback URL, keep the token out of logs and source control, and limit who can read it.

Make Postscript webhooks production-ready. Hookdeck Event Gateway checks the Postscript-Signature token on every request, responds to Postscript straight away, and keeps retrying delivery to your service long after Postscript's one-hour window would have closed.

Postscript webhook limitations and pain points

A one-hour retry window

The Problem: If your endpoint is down for more than an hour, events are lost.

Why It Happens: When Postscript does not get a 200, or the response is slow, it retries every 5 minutes for up to 1 hour.

Workarounds:

  • Return 200 before doing any complex logic, as Postscript recommends.
  • After a long outage, reconcile subscriber status through the Postscript API.

How Hookdeck Can Help: Hookdeck accepts each event and stores it before forwarding, so Postscript gets its 200 even when your service is down. Delivery to your service then uses retries you configure, over as long a period as you need.

Duplicate events

The Problem: The same event can arrive more than once.

Why It Happens: Postscript says duplicates can happen "for a variety of reasons", including retries after a slow response.

Workarounds:

  • Make the handler idempotent. Build the key from event, resource_id, event_time and, where there is one, the record's own ID such as event_data.id on shop.incoming_message. See our guide to webhook idempotency.
  • Do not deduplicate on webhook_id alone. Postscript does not say whether it identifies the delivery or the subscription, and a subscription's id has the same UUID format, so it may be the same on every event from one subscription.

How Hookdeck Can Help: Deduplication can drop requests whose chosen fields, such as event, resource_id and event_time, match one seen within a time window.

A fixed token, not a signature

The Problem: The header name suggests body signing, but a leaked token lets anyone send requests your receiver will accept.

Why It Happens: Postscript-Signature carries a fixed per-account token. Postscript describes the tokens as "fixed MD5 hashes", and documents no way to rotate one.

Workarounds:

  • Store the token as a secret and compare it in constant time over HTTPS.
  • For sensitive actions, confirm the subscriber's state through the API before acting on the event.

How Hookdeck Can Help: Hookdeck's Postscript source checks the token on every request. Hookdeck answers Postscript with a 200 either way, but a request with a missing or wrong token is recorded as failed verification and never reaches your service.

Incomplete message coverage

The Problem: Some inbound texts never produce a shop.incoming_message.

Why It Happens: Postscript notes that keywords, subscriber lifecycle messages and similar may not come through.

Workarounds:

  • Use shop.subscriber.opt_in and shop.subscriber.opt_out for consent changes rather than parsing inbound text.
  • Do not build features that depend on seeing every keyword reply.

How Hookdeck Can Help: Send each subscription to the same Hookdeck source, then route events by event with filters and separate connections, so consent events and messages go to the services that handle them.

Best practices

Treat the token as a password

Read it once from GET /api/v2/webhooks/token, store it in your secret manager, and compare it whole. It is a fixed value, not something computed per request.

Accept unknown events

Postscript may add event types. Return 200 for events you do not handle yet, and log them, so a new type never triggers an hour of retries.

Acknowledge fast and process asynchronously

Postscript retries slow responses as well as failures. Verify the token, store the event, return 200, then do the work. See why to process webhooks asynchronously.

Conclusion

Postscript webhooks report a shop's inbound texts, opt-ins, opt-outs and collected emails in a consistent envelope, one event per subscription. The Postscript-Signature header is a fixed account token rather than a body signature, so compare it in constant time over HTTPS, keep it secret, and make the handler idempotent. Plan for the one-hour retry window and for events Postscript adds later.

Hookdeck Event Gateway checks the token, answers Postscript immediately, and keeps retrying delivery to your service for as long as you need.

Get started with Hookdeck for free and handle Postscript webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.