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
- An event occurs at A3BC (for example a credential validation).
- A3BC sends a JSON
POSTto your URL, with theX-Webhook-Signatureheader. - Your server verifies the signature, checks it has not already processed this
id, answers204, then processes in the background. - If you answer anything other than
200,201or204, A3BC resends the same notification according to its retry strategy (sameid, higherattempt). On429it slows down. - After
max_retriesattempts, 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.
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 newclient_credentialstoken and call the API; store non-empty claims. - On
revoke, stop using the data according to your business rules: the API then answers403(code00020).