A webhook is a URL you register with Ponch. When one of its
events happens, Ponch sends that URL an HTTP POST with the event
payload, signed so you can check it came from Ponch.
Connecting Zapier or Make creates and removes webhooks for you. Everything below is for building your own receiver.
POST /integrations/webhooksSubscribe a URL to one or more events.
| Body field | Type | |
|---|---|---|
url |
string | Required. An http(s) URL that accepts POST. Use https |
events |
list of strings | Required, not empty. Any of the six event types |
template_slug |
string or null |
Only send events for this template. Omit or null for every template in the company |
description |
string | A note for yourself |
source |
string | Optional label for the client that created the hook (e.g. zapier, make). Shown in the dashboard; at most 16 characters are kept |
{
"url": "https://hooks.example.com/ponch",
"events": ["stamp.added", "reward.earned"],
"template_slug": null,
"description": "Loyalty sync"
}
Response 201:
{
"id": "f0e2d4c6-a8b0-4c2e-9a6b-8d0f2a4c6e81",
"url": "https://hooks.example.com/ponch",
"events": ["stamp.added", "reward.earned"],
"template_slug": null,
"description": "Loyalty sync",
"source": "",
"is_active": true,
"consecutive_failures": 0,
"last_delivered_at": null,
"auto_disabled_at": null,
"created_at": "2026-10-20T00:28:51.231Z",
"secret": "-l2JqF3xC89GzsYyjcppJuWHpXgXpmkoj6b4pkOSQxs"
}
Save secret now. It's returned only here and by
rotate-secret, and you need
it to verify signatures.
| Error | When |
|---|---|
400 |
Unknown template_slug |
422 |
Invalid url, empty events, or an unknown event type |
| Endpoint | |
|---|---|
GET /integrations/webhooks |
List the company's webhooks (no secrets) |
GET /integrations/webhooks/{webhook_id} |
One webhook (no secret). 404 if not found |
PATCH /integrations/webhooks/{webhook_id} |
Change any of url, events, template_slug, description, is_active. Fields you leave out are unchanged; "template_slug": null widens the hook to every template. Setting is_active: true also resets the failure count |
DELETE /integrations/webhooks/{webhook_id} |
Unsubscribe. Returns 204. The hook's delivery log is deleted with it |
POST /integrations/webhooks/{webhook_id}/rotate-secret |
New secret. See below |
GET /integrations/webhooks/{webhook_id}/deliveries |
The delivery log. See below |
POST /integrations/webhooks/deliveries/{delivery_id}/replay |
Send one delivery again. See below |
POST /integrations/webhooks/{webhook_id}/rotate-secret{"id": "f0e2d4c6-a8b0-4c2e-9a6b-8d0f2a4c6e81", "secret": "QgGZ7w_O7hzZZWBtxvd4SVQrVsWnYIuIw-C_6F1eiiA"}
The old secret stops working at once: the next delivery is signed with the new one. To avoid rejecting deliveries in between, have your receiver accept either secret while you switch over.
GET /integrations/webhooks/{webhook_id}/deliveriesThe most recent deliveries, newest first. limit defaults to 50, max 200.
[
{
"id": "e261392d-1e81-4f91-821b-08f2771ce67d",
"event_type": "stamp.added",
"event_id": "8d59fcdc-63d1-433f-9496-5e291e14cb45",
"status": "delivered",
"attempt_count": 1,
"response_status": 200,
"response_body": "ok",
"delivered_at": "2026-10-20T00:28:52.104Z",
"next_retry_at": null,
"created_at": "2026-10-20T00:28:51.258Z"
}
]
status is pending (waiting or between retries), delivered or failed.
response_status and response_body are what your server answered on the
latest attempt (or the network error, if there was no answer).
POST /integrations/webhooks/deliveries/{delivery_id}/replayQueue a delivery to be sent again from scratch, with its original payload.
Returns 202 and the delivery, reset to pending. Use it after fixing a
receiver that was failing. A replay to a deactivated webhook fails, so
re-enable the webhook first.
Each delivery is a POST with a JSON body (the event payload) and
these headers:
| Header | |
|---|---|
Content-Type |
application/json |
User-Agent |
Ponch-Webhook/1.0 |
X-Ponch-Event |
The event type, e.g. stamp.added |
X-Ponch-Event-Id |
The payload's event_id |
X-Ponch-Webhook-Id |
The webhook's id |
X-Ponch-Signature |
t=<unix timestamp>,v1=<hex signature>. See below |
Respond with any 2xx within 10 seconds. Do slow work after responding,
e.g. put the event on a queue first.
What happens with each response:
| Your response | Ponch does |
|---|---|
2xx |
Marks it delivered |
410 Gone |
Deactivates the webhook straight away and stops sending to it. This is how to unsubscribe from the receiving end. It isn't counted as a failure |
408, 429, 5xx, a timeout, or no connection |
Tries again. Up to 5 attempts in all, waiting 1 min, 5 min, 30 min and 2 h between them |
Any other 4xx |
Marks it failed, no retry: the same request would get the same answer |
After 10 failed deliveries in a row (counting a delivery as failed once it's
out of attempts), Ponch deactivates the webhook. Any successful delivery resets
the count. To turn the webhook back on, click Re-enable under
Integrations → Webhooks in the dashboard, or PATCH it with
{"is_active": true}.
While a webhook is inactive, events for it are not queued. To catch up on
what you missed, use the events feed with since.
Deliveries can arrive out of order and more than once. Order by occurred_at
and deduplicate on id. See Delivery is at least once.
Check the signature on every delivery before trusting it:
t and v1 from X-Ponch-Signature.secret as the key (the string
exactly as returned, UTF-8 encoded, not base64-decoded), over t, then
a ., then the raw request body bytes.v1 in constant time.t is more than 300 seconds from your current time.
This stops someone resending an old delivery they captured.Sign the bytes you received. If you parse the JSON and serialize it again, the bytes change and the signature won't match.
import hashlib
import hmac
import time
def verify_ponch_signature(secret: str, body: bytes, header: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
try:
ts = int(parts["t"])
sig = parts["v1"]
except (KeyError, ValueError):
return False
if abs(time.time() - ts) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{ts}.".encode() + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig)
In Flask: verify_ponch_signature(SECRET, request.get_data(), request.headers.get("X-Ponch-Signature", "")).
In Django: pass request.body.
const crypto = require("node:crypto");
function verifyPonchSignature(secret, rawBody, header, tolerance = 300) {
const parts = Object.fromEntries(
(header || "").split(",").map((p) => {
const i = p.indexOf("=");
return [p.slice(0, i), p.slice(i + 1)];
}),
);
const ts = Number.parseInt(parts.t, 10);
if (!Number.isFinite(ts) || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - ts) > tolerance) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${ts}.`)
.update(rawBody)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
With Express, read the raw body so you sign the original bytes:
app.post("/ponch", express.raw({ type: "application/json" }), (req, res) => {
if (!verifyPonchSignature(process.env.PONCH_WEBHOOK_SECRET, req.body, req.get("X-Ponch-Signature"))) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body);
res.sendStatus(200);
// handle event...
});
Answer a bad signature with 400, not 410: a 410 would deactivate the
webhook.
commit 5e140ef5493db7e98830f4b7ef4d620de5419518 Author: Peter Sanchez <peter@netlandish.com> Date: 2026-10-20T06:45:49-06:00 add proper title and descriptions to each page