Propelus
Webhooks

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.

  1. Register. Call POST /v2/webhooks with the eventType you want, the destination url, and optionally a signature.key (HMAC secret) and authentication headers. The server stores the record with status: pending.

  2. 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 carries code: created_not_verified and the webhook stays pending until you confirm the code.
    • If your endpoint replies non-2xx (or the request fails), the response carries code: created_failed, the record is persisted with status: failed, and the returned id is the handle you'll use to retry.
  3. Activate. Capture the 6-character code your endpoint received and submit it to POST /v2/webhooks/{webhookId}/activate. The webhook transitions to active and 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"
  }
}
FieldTypeDescription
eventTypestringOne of the v2 event-type names listed below.
timestampstring (ISO 8601)The moment the source action completed on Propelus side.
detailobjectEvent-specific payload. See the table below for what each event carries.

Headers

HeaderWhen sentPurpose
Content-Type: application/jsonAlwaysBody is JSON.
x-propelus-message-idAlwaysIdentifies 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-signatureWhen signature.key was set on the webhookBase64-encoded HMAC-SHA256 of the raw body joined with your clientId. See Verifying the signature.
Configured authenticationWhen authentication was set on the webhookThe header Propelus uses depends on the configured method: apiKeyx-api-key: <value>; basicAuthorization: Basic <value>; bearerAuthorization: Bearer <value>. The value is sent verbatim — Propelus does not base64-encode it for you.

Event types

eventTypeTriggered by
credentialVerifiedA 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.
credentialsBatchCompletedAll credentials in a POST /v2/credentials batch finished. The detail carries the batch summary.
credentialMonitoredA monitored credential was re-checked at its scheduled cadence. Fired on every periodic check, regardless of whether anything changed.
monitoredCredentialChangedA monitored credential's state changed compared to the previous check. Fire-on-change companion to credentialMonitored.
automationStatusChangedA 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.
entityMonitoredA monitored entity (adverseCheckEssentials, adverseCheckPro, see POST /v2/entities) was re-checked at its scheduled cadence.
monitoredEntityChangedA 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_key is the value you set as signature.key when registering or patching the webhook.
  • raw_body is 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.
  • clientId is 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-signature header 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 "", 200

Common 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 == / .equals leaks 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, where clientId is 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 .env can 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 with eventType. Do not rely on x-propelus-message-id alone: 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/webhooks returns every webhook you've configured. The response masks signature.key and authentication.value as ******** — 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 new signature.key rotates 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 explicit null clears. Clearing authentication re-runs the activation handshake (it changes how Propelus reaches your URL); clearing signature does 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 WebhookPOST /v2/webhooks
  • List WebhooksGET /v2/webhooks
  • Update WebhookPATCH /v2/webhooks/{webhookId}
  • Delete WebhookDELETE /v2/webhooks/{webhookId}
  • Activate WebhookPOST /v2/webhooks/{webhookId}/activate

On this page