Webhook signatures and delivery
Check that a webhook delivery really came from Inkwave, rotate the signing secret, and know what counts as delivered and what happens when a delivery fails.
Every webhook delivery is signed with your endpoint's secret, so your server can tell a real delivery from a forged one. This page shows how to check the signature and explains how deliveries succeed and fail. The events themselves are listed in Webhook events.
The request
Each delivery is one HTTPS POST to your endpoint URL, with a JSON body and these headers:
| Header | Value |
|---|---|
Content-Type | application/json |
X-Inkwave-Event | The event's name, the same as event_type in the body, such as post.sent. |
X-Inkwave-Signature | The HMAC-SHA256 of the raw request body, keyed with the endpoint's secret, written as lowercase hexadecimal. |
There is no timestamp or version in the signature header: it is the bare hex digest, 64 characters long.
Finding the secret
Open the endpoint under Settings, Webhooks to see its Endpoint Secret. Only the owner and admins can open that page. Keep the secret on your server, never in a browser or a public repository.
The key is the secret exactly as shown, used as a text string (its UTF-8 bytes). Don't decode it from hex first.
Checking a signature
- Read the request body as raw bytes, before any JSON parsing. Parsing and re-serialising the JSON changes the bytes and the signature will not match.
- Compute the HMAC-SHA256 of those bytes with your secret, as lowercase hex.
- Compare it with the
X-Inkwave-Signatureheader using a constant-time comparison, not==, so the comparison's timing reveals nothing about the expected value. - If they differ, or the header is missing, answer
401and ignore the delivery.
Node.js
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";
export function isFromInkwave(rawBody, signatureHeader, secret) {
const expected = Buffer.from(createHmac("sha256", secret).update(rawBody).digest("hex"), "utf8");
const received = Buffer.from(String(signatureHeader ?? ""), "utf8");
if (received.length !== expected.length) return false;
return timingSafeEqual(received, expected);
}
const app = express();
app.post("/inkwave-webhook", express.raw({ type: "application/json" }), (req, res) => {
const signature = req.get("X-Inkwave-Signature");
if (!isFromInkwave(req.body, signature, process.env.INKWAVE_WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString("utf8"));
res.sendStatus(200);
handleEvent(event);
});
express.raw keeps the body as the exact bytes received. With express.json in front of this route, the original bytes are gone and every signature check fails.
Python
import hashlib
import hmac
import json
import os
from flask import Flask, abort, request
app = Flask("inkwave-webhooks")
def is_from_inkwave(raw_body: bytes, signature_header: str, secret: str) -> bool:
expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest().encode("ascii")
return hmac.compare_digest(expected, (signature_header or "").encode("utf-8"))
@app.post("/inkwave-webhook")
def inkwave_webhook():
raw_body = request.get_data()
signature = request.headers.get("X-Inkwave-Signature", "")
if not is_from_inkwave(raw_body, signature, os.environ["INKWAVE_WEBHOOK_SECRET"]):
abort(401)
event = json.loads(raw_body)
handle_event(event)
return "", 200
request.get_data() returns the body bytes as received. hmac.compare_digest is the constant-time comparison; both sides are bytes, so a header with non-ASCII characters is simply a mismatch rather than an error.
Rotating the secret
Rotate the secret when it may have been seen by someone who shouldn't have it. On the endpoint's page, click the button beside the secret ("Generate a new signing secret") and confirm with Rotate.
- The new secret takes effect at once. Every delivery after that is signed with it, and the old secret stops working straight away. There is no period in which both are valid.
- The endpoint keeps its id, its events and its delivery log.
- Until your server has the new secret, its signature checks fail and those deliveries are lost (see below). Have the change to your server ready before you rotate, and update it as soon as the new secret appears. If your server can hold two secrets, accepting either one while you switch over shortens the gap to nothing.
What counts as delivered
- A status in the 200s from your endpoint counts as delivered. Anything else, such as a
4xxor a5xx, counts as a failure, as does an endpoint that can't be reached. - Answer within 10 seconds. A delivery that gets no answer in that time counts as failed. Send the
200first and do slow work, such as calling other services, afterwards or in a queue. - What your server answers, status and body, is stored in the delivery log, so don't put anything secret in the response.
When a delivery fails
How a failure is handled depends on what your server answered.
- Tried again: a
5xx, a408or a429, no answer within 10 seconds, or no answer at all (a DNS failure, or a refused or reset connection). The delivery is tried up to 5 times in all, waiting longer between each try: about 10 seconds, then 20, 40 and 60. If one of the tries gets a status in the 200s, the delivery is listed as Pass. - Not tried again: any other status outside the 200s, such as
400,401or404. Your server turned the request down, and sending the same request again would get the same answer.
Either way, a delivery shows once in the delivery log, with its final result. When every try failed, it is listed as Fail with the status and body of the last try, if your server sent one. A failed delivery is not sent again after that, and there is no button to resend it.
Don't count on these retries: a server that is down for more than a few minutes misses the events that happen meanwhile, and they aren't sent later.
Test deliveries from Send Test Event are tried only once, so you see the result straight away.
Events are also not saved up while an endpoint is switched off, while your plan doesn't include webhooks, or while the publication is suspended. They are dropped, and sending starts again with the next event once the endpoint can receive.
Because of the retries, the same delivery can arrive more than once. For example, a connection that breaks after your server has received the request, or a reply that takes longer than 10 seconds, leads to a retry. Make your handler idempotent, so running it twice for the same delivery does no harm. A delivery has no id of its own; a retried delivery has the same body, so de-duplicate on its event_type, created_at and data.
The delivery log
Open an endpoint to see its delivery log: the latest 50 attempts, newest first. Each shows Pass or Fail, the event type, an attempt ID and the time. View details shows the HTTP status your server answered with, whether the attempt was a Test or Live delivery, and the full request and response bodies. The attempt ID is Inkwave's own and is not sent to your endpoint.
Use Send Test Event to check your endpoint and your signature code end to end. A test delivery is signed exactly like a real one, with is_test set to true.
Webhook events
Every event Inkwave sends to a webhook endpoint, what makes it fire, the fields of its data and an example delivery.
Reader endpoints
The public links and forms on your publication's site that you can build on, such as embedded subscribe forms, custom sign-up links and one-click unsubscribe.