Webhooks Overview
How to register a webhook, event types, and verifying signatures.
Webhooks let Propelus push asynchronous and monitoring events to your endpoint in real time, replacing polling. Each event is delivered as a single HTTP POST with a JSON body and, when configured, an HMAC signature header so you can prove the request came from us.
End-to-end flow
Registering and activating webhooks from Propelus is a three-step process. You will set the destination URL to receive the events.
-
Register. Call
POST /v2/webhookswith theeventTypeyou want, the destinationurl, and optionally asignature.key(HMAC secret) andauthenticationheaders. The server stores the record withstatus: pending. -
Activation handshake. Inside the same request, the server synchronously POSTs an activation payload to your URL:
{ "event": "activation", "code": "AB12CD" }- If your endpoint replies
2xx, the response carriescode: created_not_verifiedand the webhook stayspendinguntil you confirm the code. - If your endpoint replies non-
2xx(or the request fails), the response carriescode: created_failed, the record is persisted withstatus: failed, and the returnedidis the handle you'll use to retry.
- If your endpoint replies
-
Activate. Capture the 6-character
codeyour endpoint received and submit it toPOST /v2/webhooks/{webhookId}/activate. The webhook transitions toactiveand starts receiving real events.
When activation fails
If created_failed came back, you have a failed record holding the (client, eventType) slot. Recover with PATCH /v2/webhooks/{webhookId} — a PATCH on a failed record always re-runs the activation handshake, even if you don't change url or authentication. There's no need to delete and recreate.
pending and failed records carry a 7-day TTL; abandoned webhooks are pruned automatically. Soft-deleted webhooks are kept for 48 hours in deleted status and then removed.
One webhook per event type
At most one webhook per (client, eventType) may be in active, pending, or failed state at the same time. POST /v2/webhooks rejects duplicates with 400; use PATCH to update the existing record or DELETE to free the slot.
Event payload
Every event Propelus sends shares the same outer shape. Only the contents of detail change between event types.
{
"eventType": "credentialVerified",
"timestamp": "2026-06-05T14:32:11.482Z",
"detail": {
"...": "event-specific payload"
}
}| Field | Type | Description |
|---|---|---|
eventType | string | One of the v2 event-type names listed below. |
timestamp | string (ISO 8601) | The moment the source action completed on Propelus side. |
detail | object | Event-specific payload. See the table below for what each event carries. |
Headers
| Header | When sent | Purpose |
|---|---|---|
Content-Type: application/json | Always | Body is JSON. |
x-propelus-message-id | Always | Identifies the source message the delivery came from. A single source message can fan out to multiple event deliveries that all share the same id, and a retry re-sends the whole batch with that same id — so it is not unique per event. Don't use it on its own as a dedup key (see Idempotency). |
x-propelus-signature | When signature.key was set on the webhook | Base64-encoded HMAC-SHA256 of the raw body joined with your clientId. See Verifying the signature. |
Configured authentication | When authentication was set on the webhook | The header Propelus uses depends on the configured method: apiKey → x-api-key: <value>; basic → Authorization: Basic <value>; bearer → Authorization: Bearer <value>. The value is sent verbatim — Propelus does not base64-encode it for you. |
Event types
eventType | Triggered by |
|---|---|
credentialVerified | A single credential in a batch from POST /v2/credentials finished verifying. The detail carries the same credential result you'd get back from GET /v2/credentials/{batchId} for that item. |
credentialsBatchCompleted | All credentials in a POST /v2/credentials batch finished. The detail carries the batch summary. |
credentialMonitored | A monitored credential was re-checked at its scheduled cadence. Fired on every periodic check, regardless of whether anything changed. |
monitoredCredentialChanged | A monitored credential's state changed compared to the previous check. Fire-on-change companion to credentialMonitored. |
automationStatusChanged | A profession's automation status changed (e.g. a profession became automated). Delivered to clients subscribed to this event whose config exposes the professions product. The detail carries the affected profession and state. |
entityMonitored | A monitored entity (adverseCheckEssentials, adverseCheckPro, see POST /v2/entities) was re-checked at its scheduled cadence. |
monitoredEntityChanged | A monitored entity's state changed compared to the previous check. Fire-on-change companion to entityMonitored. |
The shape of detail mirrors the response payload of the related synchronous endpoint (e.g. credentialVerified.detail matches the per-credential entry returned by GET /v2/credentials/{batchId}). Inspect the related endpoint's response schema in the sidebar for the field-level reference.
Verifying the signature
If you configured signature.key on the webhook, Propelus signs every delivery and sends the result in x-propelus-signature. Verifying that signature is how you prove the request really came from Propelus and wasn't replayed or tampered with.
Algorithm
signature = base64( HMAC-SHA256( signature_key, raw_body + "." + clientId ) )signature_keyis the value you set assignature.keywhen registering or patching the webhook.raw_bodyis the request body as bytes — exactly what arrived on the wire. Do not parse and re-serialize the JSON before signing; re-serialization will reorder keys or normalize whitespace and the signatures will not match.clientIdis your own Propelus client identifier. You know it; it's part of every credential you use to call our API.- Compare the result to the value in the
x-propelus-signatureheader using a constant-time equality check to avoid timing attacks.
Recipes
The examples below assume you've set signature.key to a strong secret (≥ 32 random bytes, base64-encoded) and stored it somewhere your handler can read — environment variable, secret manager, etc. They reject the request with 401 on signature mismatch so Propelus marks the delivery as failed.
TypeScript / Node.js (Express)
import crypto from 'node:crypto';
import express from 'express';
const app = express();
// IMPORTANT: capture the raw bytes BEFORE any JSON parser touches them.
app.post(
'/webhooks/propelus',
express.raw({ type: 'application/json' }),
(req, res) => {
const rawBody = req.body as Buffer; // raw bytes, not parsed JSON
const signatureHeader = req.header('x-propelus-signature') ?? '';
const expected = crypto
.createHmac('sha256', process.env.PROPELUS_WEBHOOK_KEY!)
.update(rawBody)
.update(`.${process.env.PROPELUS_CLIENT_ID!}`)
.digest('base64');
const a = Buffer.from(expected);
const b = Buffer.from(signatureHeader);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send('invalid signature');
}
const event = JSON.parse(rawBody.toString('utf8'));
// ... handle event.eventType, event.timestamp, event.detail
res.status(200).end();
}
);C# / .NET (ASP.NET Core)
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("webhooks/propelus")]
public class PropelusWebhookController : ControllerBase
{
private static readonly string Secret = Environment.GetEnvironmentVariable("PROPELUS_WEBHOOK_KEY")!;
private static readonly string ClientId = Environment.GetEnvironmentVariable("PROPELUS_CLIENT_ID")!;
[HttpPost]
public async Task<IActionResult> Receive()
{
// Read the raw body BEFORE model binding parses it.
using var reader = new StreamReader(Request.Body, Encoding.UTF8);
var rawBody = await reader.ReadToEndAsync();
if (!Request.Headers.TryGetValue("x-propelus-signature", out var headerValues))
{
return Unauthorized();
}
var receivedSignature = headerValues.ToString();
var message = Encoding.UTF8.GetBytes($"{rawBody}.{ClientId}");
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(Secret));
var expectedSignature = Convert.ToBase64String(hmac.ComputeHash(message));
// Constant-time comparison: convert both to byte arrays of equal length first.
var expectedBytes = Encoding.UTF8.GetBytes(expectedSignature);
var receivedBytes = Encoding.UTF8.GetBytes(receivedSignature);
if (expectedBytes.Length != receivedBytes.Length ||
!CryptographicOperations.FixedTimeEquals(expectedBytes, receivedBytes))
{
return Unauthorized();
}
// rawBody parses to { eventType, timestamp, detail }
return Ok();
}
}Java (Spring Boot)
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Base64;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/webhooks/propelus")
public class PropelusWebhookController {
private static final String SECRET = System.getenv("PROPELUS_WEBHOOK_KEY");
private static final String CLIENT_ID = System.getenv("PROPELUS_CLIENT_ID");
// Bind the body as a raw String — do NOT use @RequestBody on a parsed type, the JSON
// round-trip will lose the exact bytes used to compute the signature on our side.
@PostMapping
public ResponseEntity<String> receive(
@RequestHeader("x-propelus-signature") String receivedSignature,
@RequestBody String rawBody) throws Exception {
Mac hmac = Mac.getInstance("HmacSHA256");
hmac.init(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] message = (rawBody + "." + CLIENT_ID).getBytes(StandardCharsets.UTF_8);
String expectedSignature = Base64.getEncoder().encodeToString(hmac.doFinal(message));
// MessageDigest.isEqual is constant-time on modern JDKs.
byte[] a = expectedSignature.getBytes(StandardCharsets.UTF_8);
byte[] b = receivedSignature.getBytes(StandardCharsets.UTF_8);
if (!MessageDigest.isEqual(a, b)) {
return ResponseEntity.status(401).body("invalid signature");
}
// Parse rawBody now and dispatch on eventType
return ResponseEntity.ok().build();
}
}Python (Flask)
import base64
import hashlib
import hmac
import os
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["PROPELUS_WEBHOOK_KEY"].encode("utf-8")
CLIENT_ID = os.environ["PROPELUS_CLIENT_ID"]
@app.post("/webhooks/propelus")
def receive():
# request.get_data() returns the raw bytes as received. Do NOT use request.json here,
# parsing + re-serializing will not match what Propelus signed.
raw_body = request.get_data()
received_signature = request.headers.get("x-propelus-signature", "")
message = raw_body + b"." + CLIENT_ID.encode("utf-8")
expected_signature = base64.b64encode(
hmac.new(SECRET, message, hashlib.sha256).digest()
).decode("utf-8")
if not hmac.compare_digest(expected_signature, received_signature):
abort(401)
# raw_body parses to {"eventType": ..., "timestamp": ..., "detail": ...}
return "", 200Common signature-verification pitfalls
- Re-serialized body. The single most common mistake. Frameworks routinely parse the JSON request body before your handler runs; computing HMAC over
JSON.stringify(parsed)instead of the raw bytes will fail because key order and whitespace differ from what Propelus signed. Always read the raw body. - String equality. Comparing with
==/.equalsleaks timing information. Use the constant-time helper for your platform (crypto.timingSafeEqual,CryptographicOperations.FixedTimeEquals,MessageDigest.isEqual,hmac.compare_digest). - Wrong client id. The signed message is
rawBody + "." + clientId, whereclientIdis your client identifier — the same one you use to authenticate against the Propelus API. Hard-coding the wrong tenant is a frequent source of "always 401" symptoms in shared-environment setups. - Trailing whitespace in the secret. Reading the secret from a file or
.envcan pick up a trailing newline. Trim before using.
Operational tips
- Respond fast. Propelus considers a delivery successful as soon as your endpoint returns
2xx. Long-running work (database writes, downstream API calls) should be enqueued and acknowledged asynchronously, so your handler returns within a few seconds. - Idempotency. Deduplicate on a stable identifier inside the event
detail(e.g. credential id,batchId, monitoring id) combined witheventType. Do not rely onx-propelus-message-idalone: it identifies the upstream message, which can fan out to several distinct event deliveries that all carry the same id (and a retry re-sends them all with that id). - Listing your webhooks.
GET /v2/webhooksreturns every webhook you've configured. The response maskssignature.keyandauthentication.valueas********— the unmasked values are write-only. Save them yourself when you create the webhook; we can't show them to you later. - Rotating the secret.
PATCH /v2/webhooks/{webhookId}with a newsignature.keyrotates it. Plan a short window where your handler accepts either the old or the new signature so you don't drop events during the rollover. - Clearing the signature or authentication.
PATCH /v2/webhooks/{webhookId}with{ "signature": null }or{ "authentication": null }drops the stored value entirely — the attribute is removed from the record, not set to null. Omitting the field still means "keep what's there"; only an explicitnullclears. Clearingauthenticationre-runs the activation handshake (it changes how Propelus reaches your URL); clearingsignaturedoes not — signatures only affect what your endpoint receives on real events. - Removing a webhook.
DELETE /v2/webhooks/{webhookId}soft-deletes the record (status: deleted). It stays in that state for 48 hours before DynamoDB TTL prunes it. Soft-deleted webhooks no longer receive events.
Endpoint reference
Detailed request/response schemas for each operation live in the sidebar:
- Create Webhook —
POST /v2/webhooks - List Webhooks —
GET /v2/webhooks - Update Webhook —
PATCH /v2/webhooks/{webhookId} - Delete Webhook —
DELETE /v2/webhooks/{webhookId} - Activate Webhook —
POST /v2/webhooks/{webhookId}/activate
Profession Status Endpoint GET
Retrieve the verification status of professions verified by Propelus.
Activate Webhook POST
Confirm ownership of a `pending` webhook by submitting the 6-character `verificationCode` that the server included in the activation POST during `POST /v2/webhooks` (or `PATCH /v2/webhooks/{webhookId}` when reactivation was triggered). The code was delivered to the webhook's configured `url`; retrieve it from your endpoint's request log or capture buffer. On success the webhook transitions to `active` and the stored `activationCode` and last-handshake `response` are cleared. Returns `404` if the webhook does not exist, was soft-deleted, or belongs to another client; `400` if the webhook is already `active`, or if the submitted code does not match. To prevent code-enumeration probes revealing whether a webhook is already activated, the already-active check is performed before the code comparison.