Webhooks
Knit delivers a signed POST to your endpoint whenever something you care
about changes state. Webhooks are the intended way to track collections and
payouts — polling is a fallback for reconciliation, not a primary integration.
Configure your endpoint
Set your webhook URL and secret in the dashboard under Business → Developer → Webhooks. There is no public API for changing them.
Your endpoint must be:
- Publicly reachable over
http://orhttps://. Private, loopback, and internal addresses are rejected. - Fast. Deliveries time out after 10 seconds. Verify the signature,
enqueue the work, and return
2xximmediately. - Idempotent. The same event can arrive more than once.
Collections and
blockchain notification subscriptions can each
carry their own merchantCallbackUrl / webhookUrl, which takes precedence
over the business-level URL for that resource's events. Everything else goes
to the business-level URL.
Verifying the signature
Every delivery carries an X-Signature header: the HMAC-SHA256 of the raw
request body, keyed with your webhook secret, hex-encoded.
| Header | Value |
|---|---|
Content-Type | application/json |
Accept | application/json |
X-Signature | Hex-encoded HMAC-SHA256 of the raw body |
import crypto from "node:crypto";
// `rawBody` must be the unparsed body. Re-serialising the parsed JSON will
// produce a different byte sequence and the signature will not match.
function verify(rawBody, signatureHeader, secret) {
const expected = crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(signatureHeader ?? "", "utf8");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}import hashlib
import hmac
def verify(raw_body: bytes, signature_header: str, secret: str) -> bool:
expected = hmac.new(
secret.encode(), raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature_header or "")Capture the raw body before any JSON middleware parses it. Signing a re-serialised object is the single most common cause of "the signature never matches".
Always compare with a constant-time function, and reject the request if the header is missing.
Retries
A delivery counts as failed if your endpoint returns a non-2xx status, times out, or is unreachable. Knit then retries up to 3 times with a fixed backoff:
| Attempt | Sent |
|---|---|
| Initial | Immediately |
| Retry 1 | 1 minute after the failure |
| Retry 2 | 5 minutes after that |
| Retry 3 | 30 minutes after that |
After the third retry, delivery stops. The attempt history — status code, response body, and failure reason — is visible in the dashboard, where you can also trigger a manual resend.
The full automatic retry window is roughly 36 minutes, not hours. If your endpoint is down longer than that, reconcile by reading the affected resources back from the API rather than waiting for a redelivery.
Near-identical events for the same business within a five-minute window are collapsed into a single delivery, so a duplicated upstream signal does not become two webhooks. That is a safety net, not a guarantee — keep your handler idempotent.
Payload shape
Every payload carries an eventType. Two things vary by event, so check the
table before you write your parser:
- Envelope. Most events are wrapped in a top-level
bodyobject. Two are not. - Key casing. Some payloads use snake_case, others camelCase.
| Event | Envelope | Key casing |
|---|---|---|
COLLECTION_CREATED | body wrapper | snake_case |
COLLECTION_CONFIRMED | body wrapper | snake_case |
COLLECTION_SUCCESSFUL | body wrapper | snake_case |
COLLECTION_FAILED | body wrapper | snake_case |
PAYOUT_SUCCESSFUL | body wrapper | camelCase |
WALLET_FUNDING_SUCCESSFUL | top level | snake_case |
BLOCKCHAIN_TRANSACTION_DETECTED | top level | camelCase |
Webhook payloads do not follow the camelCase convention that API
responses do — the transformation applied to API responses is not applied to
outbound webhooks. Read each event's page for its exact keys rather than
assuming they match the equivalent GET response.
A dispatcher that handles both shapes:
app.post("/webhooks/knit", (req, res) => {
if (!verify(req.rawBody, req.get("X-Signature"), process.env.KNIT_WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
// Acknowledge first — the delivery times out after 10 seconds.
res.sendStatus(200);
const payload = req.body.body ?? req.body;
enqueue(payload.eventType, payload);
});Events
COLLECTION_CREATED — a collection was created and its address issued.
COLLECTION_CONFIRMED — the deposit reached your confirmation threshold.
COLLECTION_SUCCESSFUL — funds were credited to your API account.
COLLECTION_FAILED — the collection could not be completed.
PAYOUT_SUCCESSFUL — a payout settled on-chain.
WALLET_FUNDING_SUCCESSFUL — an API wallet received a deposit.
BLOCKCHAIN_TRANSACTION_DETECTED — a watched address sent or received
stablecoin.