A3BC — Build digital trust. Together.
Français English
Documentation/Delayed sharing/Webhooks

Webhooks

Receive and verify delayed_sharing.update and delayed_sharing.revoke notifications.

What is a webhook?

Usually, your server calls an API. With a webhook it is the opposite: A3BC calls your server when something happens (a credential was just validated, a user revoked consent). You do not poll the API in a loop: you wait to be notified.

To do so, you expose a public HTTPS URL (for example https://app.example.com/webhooks/a3bc). You give it to A3BC, which configures your “consumer”.

Notification lifecycle

  1. An event occurs at A3BC (for example a credential validation).
  2. A3BC sends a JSON POST to your URL, with the X-Webhook-Signature header.
  3. Your server verifies the signature, checks it has not already processed this id, answers 204, then processes in the background.
  4. If you answer anything other than 200, 201 or 204, A3BC resends the same notification according to its retry strategy (same id, higher attempt). On 429 it slows down.
  5. After max_retries attempts, the notification is marked as failed.

A3BC sends an HTTP POST to your consumer URL to report changes. Use these notifications to know when to query the delayed sharing API.

A webhook contains no personal data: only the context needed to call the right API.

Delivery

Your endpoint must answer 200, 201 or 204. Otherwise the notification is resent following a configurable retry strategy. A 429 adapts the sending rate to the one you can sustain.

Payload

Attribute Type Description
id string Unique id, the same on every retry of a given notification.
name string Webhook name.
timestamp_ms number Creation timestamp, in milliseconds.
attempt number Attempt number.
data object Content specific to the webhook.
{
  "id": "84a459b6-d297-11ec-8ff0-c3c3ea77040f",
  "name": "delayed_sharing.update",
  "timestamp_ms": 1651476325505,
  "attempt": 1,
  "data": {
    "delayed_sharing": "https://.../delayed-sharing/dddb6b7c-...",
    "new_claims": ["private:ssn", "private:ssn_key"]
  }
}

Events

delayed_sharing.update

New claims available, validation failure, etc.

Attribute Type Required Description
delayed_sharing string yes URL of the delayed sharing API.
new_claims list no Names of newly available claims. Values are obtained through the API.
unavailable_claims list no Claims that will not be available.
unavailable_claims_reason enum no user_deletion (the user deleted the credential) or validation_failed (verification failed).

delayed_sharing.revoke

The user revoked their consent: claims are no longer available.

Attribute Type Required Description
delayed_sharing string yes URL of the delayed sharing API.

Signature

If signing is enabled, each request carries the X-Webhook-Signature header in the form version:HEX(hmac-sha256(payload)), for example:

X-Webhook-Signature: v1:006974CFD99911E1C67870A0FD57F4BAF51B4C9B02A339590D97EDB0D7D94FA7

The version identifies the signing key (versioned, rotation possible). The HMAC-SHA256 covers the raw body of the request.

In our integration the key is a 44-character base64 string (32 bytes): you must decode it before computing the HMAC, then compare in uppercase. The guide does not state this.
import base64, hashlib, hmac

SECRETS = {"v1": "<received base64 key>"}

def verify(raw_body: bytes, header: str) -> bool:
    version, digest = header.split(":", 1)
    secret = SECRETS.get(version.strip().lower())
    if not secret:
        return False
    key = base64.b64decode(secret.strip())
    expected = hmac.new(key, raw_body, hashlib.sha256).hexdigest().upper()
    return hmac.compare_digest(expected, digest.strip().upper())
import { createHmac, timingSafeEqual } from "node:crypto";

const SECRETS = { v1: "<received base64 key>" };

export function verify(rawBody, header) {
  const [version, digest] = header.split(":");
  const secret = SECRETS[version.trim().toLowerCase()];
  if (!secret) return false;
  const key = Buffer.from(secret.trim(), "base64");
  const expected = createHmac("sha256", key).update(rawBody).digest("hex").toUpperCase();
  const given = digest.trim().toUpperCase();
  return expected.length === given.length &&
    timingSafeEqual(Buffer.from(expected), Buffer.from(given));
}

Always compute the HMAC on the received bytes, before any JSON parsing.

Receiving a webhook: the essential code

The end-to-end example contains a complete server. Here are the points to watch:

@app.post("/webhooks/a3bc")
def webhook():
    raw = request.get_data()                       # 1. raw bytes, BEFORE any JSON parsing
    if not signature_ok(raw, request.headers.get("X-Webhook-Signature", "")):
        return {"error": "invalid_signature"}, 401  # 2. reject anything not signed by A3BC
    event = json.loads(raw)
    if event["id"] in SEEN_EVENTS:                 # 3. a retransmission: already processed
        return "", 204
    SEEN_EVENTS.add(event["id"])
    enqueue(event)                                 # 4. process in the background
    return "", 204                                 # 5. answer fast

Do not parse then re-serialize the JSON to compute the signature: the slightest difference in spaces or key order makes it wrong.

Testing without waiting for A3BC

Simulate a signed webhook to your local server (Python):

import base64, hashlib, hmac, json, time, uuid, requests

SECRET = "<webhook base64 key>"
payload = {"id": str(uuid.uuid4()), "name": "delayed_sharing.update",
           "timestamp_ms": int(time.time() * 1000), "attempt": 1,
           "data": {"delayed_sharing": "https://…/delayed-sharing/<id>", "new_claims": ["private:ssn"]}}
raw = json.dumps(payload, separators=(",", ":")).encode()          # what you sign = what you send
sig = "v1:" + hmac.new(base64.b64decode(SECRET), raw, hashlib.sha256).hexdigest().upper()
r = requests.post("http://localhost:5000/webhooks/a3bc", data=raw,
                  headers={"Content-Type": "application/json", "X-Webhook-Signature": sig})
print(r.status_code)   # 204 expected; replay the same send: 204 too (deduplication)

The same in shell (OpenSSL):

BODY='{"id":"a1","name":"delayed_sharing.revoke","data":{"delayed_sharing":"https://…"}}'
KEY_HEX=$(printf '%s' "$SECRET" | base64 -d | od -v -An -tx1 | tr -d ' \n')
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -mac HMAC -macopt hexkey:$KEY_HEX -hex | awk '{print toupper($NF)}')
curl -i -X POST http://localhost:5000/webhooks/a3bc \
  -H "Content-Type: application/json" -H "X-Webhook-Signature: v1:$SIG" --data-binary "$BODY"

Variants to play each time: wrong signature (401 expected), replayed send (204 without reprocessing), body changed by one character (401), unknown event (204 with no effect).

For tests with real A3BC webhooks locally, expose your server with an HTTPS tunnel (ngrok, cloudflared) and give that URL to A3BC.

Troubleshooting

Observation Likely cause
401 on your server for all notifications Key not base64-decoded, wrong key version, body modified by a proxy or framework before verification, case-sensitive comparison (A3BC sends uppercase).
The same notification arrives several times Normal: retransmission after a status other than 200/201/204, or a too-slow answer. Deduplicate on id.
No notification Consumer not configured or inactive (active), URL unreachable from the Internet, accept_filter excluding the event.
Notifications spaced out after errors Exponential retry strategy: the delay grows up to max.

Consumer configuration

Configuration is done manually by an A3BC operator (no self-configuration at this stage). Give your endpoint URL in the access request; A3BC sets:

Attribute Description
url Where notifications are sent.
accept_filter List of events to receive.
signature, signature_key_info none or hmac-sha256, with key version and value.
max_retries Number of attempts before the event is set to failed.
retry_strategy constant (interval) or exponential (initial, multiplier, max).
rate_limit requests_per_second (0 = unlimited) and burst.

Exponential retry example with initial=1s, multiplier=5, max=30m: 1 s, 5 s, 25 s, 2 min 5 s, 10 min 25 s, then 30 min for each following attempt.

Best practices

  • Answer quickly, then process in the background.
  • Deduplicate on id.
  • On update, get a new client_credentials token and call the API; store non-empty claims.
  • On revoke, stop using the data according to your business rules: the API then answers 403 (code 00020).