Receiving and Verifying Webhooks
Build your endpoint: the request format, HMAC signature verification, and how to respond.
This page covers everything your endpoint needs to do: read the notification, prove it came from Preczn, and respond correctly.
The request we send
Every notification is an HTTP POST with a JSON body. The body has the same four top-level fields for every event type — only data changes shape.
| Field | Description |
|---|---|
id | Unique identifier for this notification. Prefixed what_ in Live mode and what_test_ in Test mode. Use this for idempotency. |
webhookId | The subscription that produced this notification. Prefixed wh_ or wh_test_. |
eventType | The event that triggered it, such as transaction.approved. |
data | The details of what happened. Its shape depends on eventType. |
{
"id": "what_6k022ss0jj8vp9g85xv1z357m7",
"webhookId": "wh_5jg1dx62za981aqkanv3cd99zh",
"eventType": "transaction.approved",
"data": {}
}For the shape of data per event, see Transaction, Merchant, Loan, and Boarding Form payloads.
Headers
| Header | Example | Notes |
|---|---|---|
X-Preczn-Signature | v1=fd2118dc6da5... | HMAC signature of the request body. Verify this. |
User-Agent | Preczn/1.2.3 | Identifies Preczn as the sender. The version varies. |
Content-Type | application/json | |
| your custom header | Present only if you configured one on the subscription. |
How to respond
| Your response | What Preczn does |
|---|---|
Any 2xx | Treated as delivered. The subscription's failure counter resets to 0. |
Any 5xx | Retried, up to 3 times with exponential backoff. |
| Connection error, DNS failure, or timeout | Retried, up to 3 times with exponential backoff. |
Any 4xx | Not retried. Counted as a failure immediately. |
| No response within 5 seconds | Treated as a timeout and retried. |
Respond within 5 secondsThe request times out after 5 seconds. Acknowledge the notification first and do your real work afterwards — queue it, write it to a table, or hand it to a background job, then return
2xx. An endpoint that processes synchronously will start timing out under load, and enough timeouts in a row will disable the subscription entirely.
Do not return 4xx to signal "try again later"A
4xxis never retried, so the notification is lost. If you need Preczn to retry, return a5xx.
Make your handler idempotent
The same event can reach your endpoint more than once, and ordering is not guaranteed. Two mechanisms cause repeats, and they need different defenses:
- A retry — after a timeout your server actually processed, for example — re-sends the identical body, including the same top-level
id. Deduplicate onid: record theidof every notification you process and ignore repeats. - A resend, triggered from the dashboard, reuses the original
eventTypeanddatabut is issued a newid. Deduplicating onidwill not catch it.
So id alone is not sufficient. Make the effect of handling a notification idempotent as well:
- Key off the record, not the notification. Apply changes by the identifier inside
data—data.idfor a transaction,data.formIdfor a boarding form — so processing the same state twice converges instead of double-counting. - Ignore stale state. Where
datacarries a timestamp such asmodifiedOn, compare it to what you have stored and discard anything older rather than overwriting newer state.
The practical test: handling the same notification twice should leave your system in the same state as handling it once.
Verifying the signature
Every notification is signed with the subscription's signing secret, so you can confirm it genuinely came from Preczn and was not modified in transit. Verify before you act on the contents.
The signature travels in the X-Preczn-Signature header:
X-Preczn-Signature: v1=fd2118dc6da5fda8ae597a4ddfea31c691335b73613b15936a4ab49c632d3658
The current version is v1: an HMAC of the raw request body, using SHA-256, rendered as lowercase hexadecimal.
The header may contain more than one signature, comma-separated. This supports zero-downtime secret rotation and future algorithms. The request is authentic if at least one signature matches your computed value.
Steps
- Extract the
X-Preczn-Signatureheader. - Split on
,if multiple signatures are present. - Strip the
v1=prefix from each. - Compute the expected signature over the entire raw request body.
- Compare each received signature against your computed one. If any match, trust the request. If none match, discard it.
Two things break signature verificationRe-serialized JSON. Sign the raw body bytes exactly as received. If your framework parses the JSON and you re-stringify it to verify, key order and whitespace change and the signature will never match. Capture the raw body before parsing.
Character encoding. Preczn uses
UTF-8throughout. Specify UTF-8 explicitly rather than relying on a platform default.
Computing the expected signature
const crypto = require('crypto');
function generateWebhookSignature(signingSecret, requestBody) {
const hmac = crypto.createHmac('sha256', signingSecret, { encoding: "utf-8" });
const data = hmac.update(requestBody);
return data.digest('hex');
}import hmac
import hashlib
def generate_webhook_signature(signing_secret, request_body):
signing_bytes = bytearray(signing_secret.encode(encoding='UTF-8'))
request_bytes = bytearray(request_body.encode(encoding='UTF-8'))
return hmac.new(
signing_bytes,
request_bytes,
hashlib.sha256,
).hexdigest()using System;
using System.Security.Cryptography;
using System.Text;
private static string GenerateWebhookSignature(string signingSecret, string requestBody)
{
byte[] signingSecretBytes = Encoding.UTF8.GetBytes(signingSecret);
byte[] requestBodyBytes = Encoding.UTF8.GetBytes(requestBody);
HMACSHA256 hash = new HMACSHA256(signingSecretBytes);
byte[] hashBytes = hash.ComputeHash(requestBodyBytes);
return BitConverter.ToString(hashBytes).Replace("-","").ToLower();
}
Compare in constant timeUse your platform's constant-time comparison for the final check —
crypto.timingSafeEqualin Node,hmac.compare_digestin Python,CryptographicOperations.FixedTimeEqualsin .NET — rather than==. It costs nothing and avoids leaking information about the expected value through response timing.
Custom headers are not a substitute
If you configured a custom header on the subscription, it is sent verbatim with every notification and is useful for satisfying your endpoint's own authentication. It does not prove the request came from Preczn or that the body is unmodified — only the signature does that. Verify the signature regardless.
FAQ
Where do I get the signing secret?
It is returned once in the response when you create a subscription over the API, and can be revealed any time in the dashboard under Settings → Webhooks using the view icon next to the subscription. Each subscription has its own secret. Treat it as a credential — anyone holding it can forge notifications that pass verification.
My signature never matches. What is wrong?
In nearly every case the body being hashed is not the raw body. Frameworks that auto-parse JSON hand you a re-serialized version whose bytes differ from what was sent. Capture the raw body before any parsing or middleware touches it, and hash that.
Then confirm you are using the signing secret belonging to the webhookId in the payload — if you have several subscriptions, it is easy to verify against the wrong one. Finally, check you are comparing hexadecimal, not base64, and that you stripped the v1= prefix.
Why are there sometimes multiple signatures in the header?
So a signing secret can be rotated without dropping notifications. During a rotation the body is signed with more than one secret and all results are sent comma-separated, letting your endpoint accept the old and new secret at once. Always iterate over every signature present rather than reading only the first.
Can I skip verification if I allowlist Preczn's IPs?
We do not recommend it. An IP allowlist confirms where a request came from but not that its body is intact, and it breaks whenever egress addresses change. Signature verification is the supported mechanism and is cheap to implement.
Should I return 2xx even for events I do not care about?
Yes. Return 2xx for anything you successfully received, including event types you intend to ignore. Returning an error for unrecognized events counts as a delivery failure, and enough consecutive failures will disable the subscription — an ignored event type should not be able to take down your whole webhook.
How do I see what was actually sent?
Open the subscription in the dashboard and view its delivery history. Each attempt records the full request body, the response or error we received, the endpoint URL, whether it succeeded, and when it was attempted. History is retained for 15 days — see Delivery, Retries, and Failures.
How should I test my endpoint before going live?
Create a Test-mode subscription and generate activity in Test mode; Test and Live are fully isolated, so nothing you do in Test reaches a Live endpoint. Verify the signature check works by tampering with a body on purpose and confirming your handler rejects it — a verification bug that silently passes everything looks identical to a working one until it matters.
Updated about 2 hours ago
