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.

FieldDescription
idUnique identifier for this notification. Prefixed what_ in Live mode and what_test_ in Test mode. Use this for idempotency.
webhookIdThe subscription that produced this notification. Prefixed wh_ or wh_test_.
eventTypeThe event that triggered it, such as transaction.approved.
dataThe 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

HeaderExampleNotes
X-Preczn-Signaturev1=fd2118dc6da5...HMAC signature of the request body. Verify this.
User-AgentPreczn/1.2.3Identifies Preczn as the sender. The version varies.
Content-Typeapplication/json
your custom headerPresent only if you configured one on the subscription.

How to respond

Your responseWhat Preczn does
Any 2xxTreated as delivered. The subscription's failure counter resets to 0.
Any 5xxRetried, up to 3 times with exponential backoff.
Connection error, DNS failure, or timeoutRetried, up to 3 times with exponential backoff.
Any 4xxNot retried. Counted as a failure immediately.
No response within 5 secondsTreated as a timeout and retried.
❗️

Respond within 5 seconds

The 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 4xx is never retried, so the notification is lost. If you need Preczn to retry, return a 5xx.

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 on id: record the id of every notification you process and ignore repeats.
  • A resend, triggered from the dashboard, reuses the original eventType and data but is issued a new id. Deduplicating on id will 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 datadata.id for a transaction, data.formId for a boarding form — so processing the same state twice converges instead of double-counting.
  • Ignore stale state. Where data carries a timestamp such as modifiedOn, 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

  1. Extract the X-Preczn-Signature header.
  2. Split on , if multiple signatures are present.
  3. Strip the v1= prefix from each.
  4. Compute the expected signature over the entire raw request body.
  5. Compare each received signature against your computed one. If any match, trust the request. If none match, discard it.
🔡

Two things break signature verification

Re-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-8 throughout. 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 time

Use your platform's constant-time comparison for the final check — crypto.timingSafeEqual in Node, hmac.compare_digest in Python, CryptographicOperations.FixedTimeEquals in .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.


Did this page help you?