Webhooks

Truemed sends webhooks to notify your application when state changes in your integration. This guide covers the two authentication methods (signed and unsigned), how each delivers events, and how to verify inbound requests. See Event Types for the list of supported events and per-event payload schemas.

Configure webhook destinations and generate secrets in the Developer Dashboard.

Choose a delivery method

Truemed supports two mutually exclusive authentication methods for webhook delivery. The method is chosen per-webhook in the Developer Dashboard. Both methods use a webhook-specific secret; the difference is what we do with it at delivery time — HMAC-sign the body with it (signed) or send it verbatim in a header (unsigned).

Signed (recommended)Unsigned
Auth headerx-truemed-signature (HMAC-SHA256)x-truemed-api-key (plaintext shared secret)
Body shapeEnvelope with webhook_delivery_id, event_type, dataEvent-specific payload delivered flat
Tamper-evident?Yes — any byte change invalidates the signatureNo — relies on TLS + shared-secret trust
Secret exposureHMAC digest sent on the wire, signing secret never leaves either sideShared secret sent verbatim on every request
Idempotency keyDeterministic webhook_delivery_id on every deliveryDerive one from stable payload fields (e.g. payment_id)

We strongly recommend signed webhooks for any new integration. They’re tamper-evident, provide a built-in idempotency key, and cost only a few lines of code to verify. Unsigned webhooks exist for backwards compatibility; the delivery method is immutable once a webhook is created, so migrating means creating a new signed webhook alongside the unsigned one. Multiple active webhooks per sales channel are supported, so you can run both in parallel, verify the signed integration end-to-end, and retire the unsigned webhook when you’re ready — no downtime required. Manage both in the Developer Dashboard.

Signed webhooks

Envelope format

Signed webhooks deliver every event in a standard envelope:

1{
2 "webhook_delivery_id": "dlv_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
3 "event_type": "payment_session.completed",
4 "data": {
5 "payment_id": "ps_abc123",
6 "status": "captured",
7 "created_at": "2026-01-15T12:00:00Z"
8 }
9}
FieldDescription
webhook_delivery_idUnique ID for this delivery attempt (prefixed dlv_). Use for idempotency — if you receive the same ID twice, you can safely skip the duplicate. The delivery ID is deterministic: retries of the same event produce the same ID.
event_typeDot-notation event type (see Event Types below). Use for routing or quick rejection before parsing data.
dataThe full event payload. Structure varies by event type.

Event types

Dot-notation event types are used in the signed envelope’s event_type field. The data shape is event-specific — follow the API Reference link for the full schema of each event payload.

Event TypeDescriptionPayload reference
payment_session.completedA payment session’s status has changed (e.g. captured, authorized, canceled).Payment Session Complete Webhook
payment_token.updatedA payment token has been provisioned or updated.Payment Token Updated Webhook
qualification_status.updatedA qualification session has reached approved or rejected.Qualification Session Complete Webhook
product_catalog.item.eligibility_updatedA catalog item’s HSA/FSA eligibility classification has changed.Item Eligibility Updated Webhook

Signature verification

Truemed signs the body with HMAC-SHA256 and sends the signature in the x-truemed-signature header. The header contains a timestamp and one or more versioned signatures:

x-truemed-signature: t=1706108400,v0=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
ComponentDescription
tUnix epoch timestamp of when the webhook was sent. Use for staleness checks.
v0HMAC-SHA256 hex digest, computed over {timestamp}.{body} using your signing secret.

The signed payload is the string {t}.{raw_body} — the timestamp from the header, a literal period, and the raw request body bytes. Because the timestamp is included in the signed message, tampering with the t value invalidates the signature.

Verification steps

  1. Extract the t and v0 values from the x-truemed-signature header.
  2. Construct the signed payload: {t}.{raw_body} (using the raw request body bytes, not parsed JSON).
  3. Compute the HMAC-SHA256 hex digest of the signed payload using your signing secret.
  4. Compare the computed digest to v0 using constant-time comparison.
  5. Optionally, check that t is within your staleness tolerance (e.g. 5 minutes).
1import hashlib
2import hmac
3import time
4
5def verify_webhook(request, secret: str, tolerance_seconds: int = 300) -> bool:
6 header = request.headers.get("x-truemed-signature", "")
7 parts = {}
8 for part in header.split(","):
9 if "=" in part:
10 key, value = part.split("=", 1)
11 parts[key] = value
12
13 timestamp = parts.get("t", "")
14 signature = parts.get("v0", "")
15 if not timestamp or not signature:
16 return False
17
18 # Check staleness
19 try:
20 ts = int(timestamp)
21 except ValueError:
22 return False
23 if abs(time.time() - ts) > tolerance_seconds:
24 return False
25
26 # Verify signature
27 signed_payload = f"{timestamp}.{request.body.decode()}"
28 expected = hmac.new(
29 secret.encode(), signed_payload.encode(), hashlib.sha256
30 ).hexdigest()
31 return hmac.compare_digest(signature, expected)

Always compute the HMAC over the raw request body bytes exactly as received. Parsing and re-serializing JSON may change key order or whitespace, producing a different signature.

Signature versioning

The v0 prefix identifies the signing algorithm version. If Truemed ever changes the signing algorithm, a new version (e.g. v1) will be added alongside the existing one:

x-truemed-signature: t=1706108400,v0=abc123...,v1=def456...

Your integration only needs to verify the version it was built against. Older versions will continue to be sent for backward compatibility.

Unsigned webhooks

Prefer signed webhooks for new integrations. Unsigned webhooks rely entirely on TLS and a shared-secret header — there is no payload integrity check, and the secret is sent verbatim on every request. The delivery method is immutable once a webhook is created, but multiple active webhooks per sales channel are supported — create a signed webhook alongside the existing unsigned one, run them in parallel, and retire the unsigned webhook when you’re ready. See the Developer Dashboard.

Unsigned webhooks authenticate with a plaintext API key sent in the x-truemed-api-key header. The request body is the event-specific payload delivered flat — there’s no envelope, no webhook_delivery_id, and no event_type field. Refer to the API Reference page linked for each event in Event Types for the exact body shape per event.

Authenticating requests

Unsigned webhooks have no signature to verify. Compare the received x-truemed-api-key header to your stored API key:

1def verify_api_key(request, expected_key: str) -> bool:
2 received = request.headers.get("x-truemed-api-key", "")
3 return received == expected_key

Payload compatibility

Over time, Truemed may add new fields to webhook payloads or introduce new event types. These additions are intended to be non-breaking. Removals, renames, field-type changes, or new values on an existing enum (for example, a new status on payment_session.completed) are treated as breaking changes and are announced in advance via the changelog.

To stay forward-compatible with additive changes, configure your JSON validator to ignore unknown fields — strict-by-default validators (e.g. Pydantic’s extra="forbid", ajv’s additionalProperties: false) will reject payloads the first time we extend one.

Responses and Retries

Return any 2xx status code to acknowledge delivery — 204 No Content is conventional. Non-2xx responses, timeouts, and connection errors are treated as failed deliveries and retried with backoff for up to 7 days.

For signed webhooks, the webhook_delivery_id is deterministic: every retry of the same event to the same webhook reuses the same ID. Use it as your idempotency key. The signature and timestamp change on each retry attempt, so do not use them for deduplication.

For unsigned webhooks, there is no built-in idempotency key. Derive one from stable fields in the payload (for example, payment_id plus status for payment_session.completed).

Best Practices

Unless noted, these apply to both delivery methods.

  • Verify before processing. Reject requests with missing or invalid auth (signature for signed, API key for unsigned) before executing any business logic.
  • Use constant-time HMAC comparison (signed only). When comparing the received signature to your computed digest, use a timing-safe comparator (hmac.compare_digest, crypto.timingSafeEqual, Rack::Utils.secure_compare) rather than ==, so the comparison can’t leak the signature byte-by-byte.
  • Deduplicate deliveries. Signed webhooks give you a webhook_delivery_id for free; for unsigned webhooks, derive an idempotency key from stable payload fields.
  • Check staleness (signed only). Compare the header timestamp t to the current time and reject events outside your tolerance window (e.g. 5 minutes). Unsigned deliveries don’t carry a timestamp.
  • Keep your secret safe. Store the signing secret or API key in environment variables or a secrets manager — never hard-code it in source control.
  • Rotate secrets periodically. Use the Developer Dashboard to rotate your signing secret or API key without downtime.