Webhooks let your backend receive asynchronous 0xkey activity events. In the current release, webhook endpoints are organization-scoped and can subscribe to activity updates.
Current scope. 0xkey currently supports the ACTIVITY_UPDATES subscription. This emits activity lifecycle events such as activity.completed and activity.failed. Balance and transaction subscriptions are not yet enabled.
Create an endpoint
You can create and manage webhook endpoints from the Dashboard under Webhooks.
For API-based setup, submit a ACTIVITY_TYPE_CREATE_WEBHOOK_ENDPOINT activity:
curl --request POST \
--url https://api.0xkey.io/public/v1/submit/create_webhook_endpoint \
--header 'Accept: application/json' \
--header 'Content-Type: application/json' \
--header 'X-Stamp: <your-api-or-webauthn-stamp>' \
--data '{
"type": "ACTIVITY_TYPE_CREATE_WEBHOOK_ENDPOINT",
"timestampMs": "1781250000000",
"organizationId": "<organization-id>",
"parameters": {
"url": "https://example.com/webhooks/0xkey",
"name": "Production activity webhook",
"subscriptions": [
{
"eventType": "ACTIVITY_UPDATES",
"filtersJson": "",
"isActive": true
}
]
}
}'
Production webhook URLs must use https://. Local development accepts http://localhost and http://127.0.0.1.
Update or delete an endpoint
Update supports the endpoint URL, display name, and active status:
curl --request POST \
--url https://api.0xkey.io/public/v1/submit/update_webhook_endpoint \
--header 'Accept: application/json' \
--header 'Content-Type: application/json' \
--header 'X-Stamp: <your-api-or-webauthn-stamp>' \
--data '{
"type": "ACTIVITY_TYPE_UPDATE_WEBHOOK_ENDPOINT",
"timestampMs": "1781250000000",
"organizationId": "<organization-id>",
"parameters": {
"endpointId": "<endpoint-id>",
"url": "https://example.com/webhooks/0xkey",
"name": "Production activity webhook",
"isActive": true
}
}'
Updating subscriptions is not supported yet. To change subscriptions, delete and recreate the endpoint.
Delete removes the 0xkey endpoint and synchronizes the deletion to the delivery backend:
curl --request POST \
--url https://api.0xkey.io/public/v1/submit/delete_webhook_endpoint \
--header 'Accept: application/json' \
--header 'Content-Type: application/json' \
--header 'X-Stamp: <your-api-or-webauthn-stamp>' \
--data '{
"type": "ACTIVITY_TYPE_DELETE_WEBHOOK_ENDPOINT",
"timestampMs": "1781250000000",
"organizationId": "<organization-id>",
"parameters": {
"endpointId": "<endpoint-id>"
}
}'
Delivery payload
0xkey sends a JSON POST body:
{
"eventId": "976c99ff-2320-4f6b-9fb5-90cc706a8f49",
"eventType": "activity.completed",
"organizationId": "488dccf5-b0e5-46da-b830-5e86776bcf05",
"activityId": "019eb30e-254f-7270-a177-8dca730bf487",
"data": {
"activityType": "ACTIVITY_TYPE_CREATE_WALLET",
"result": {}
},
"timestamp": 1781250000000
}
eventId is stable across retries. Use it as your idempotency key.
Every signed webhook delivery includes these X-0xkey-* headers:
X-0xkey-Organization-Id: the organization that owns the event.
X-0xkey-Event-Type: the event type, for example activity.completed.
X-0xkey-Timestamp: Unix timestamp in milliseconds for this delivery attempt.
X-0xkey-Signature-Version: signature contract version. Current value: v1.
X-0xkey-Event-Id: stable event ID, unchanged across retries.
X-0xkey-Signature-Key-Id: kid for selecting the JWKS verification key.
X-0xkey-Signature-Algorithm: current value: ed25519.
X-0xkey-Signature: lowercase hex-encoded Ed25519 signature.
Verify signatures
Fetch the public verification keys from:
curl https://gateway.0xkey.io/v1/webhook-jwks
Match X-0xkey-Signature-Key-Id to the JWK kid. If the kid is unknown, refetch JWKS once before rejecting the delivery.
The signed message is the UTF-8 prefix followed by the exact raw request body bytes:
{version}.{algorithm}.{key_id}.{timestamp_ms}.{event_id}.{raw_body}
Where:
version is the X-0xkey-Signature-Version header (currently v1).
algorithm is the X-0xkey-Signature-Algorithm header (currently ed25519).
key_id is the X-0xkey-Signature-Key-Id header.
timestamp_ms is the raw X-0xkey-Timestamp header string (Unix milliseconds).
event_id is the X-0xkey-Event-Id header.
raw_body is the exact UTF-8 request body bytes received by your server.
Example verification in TypeScript with @0xkey-io/crypto:
import { verifyWebhookFromJWKS } from "@0xkey-io/crypto";
export async function handle0xkeyWebhook(req: Request) {
const rawBody = await req.text();
const result = await verifyWebhookFromJWKS({
headers: req.headers,
body: rawBody,
jwksUrl: "https://gateway.0xkey.io/v1/webhook-jwks",
maxTimestampAgeMs: 300_000,
});
if (!result.ok) {
throw new Error(`webhook verification failed: ${result.reason}`);
}
const payload = JSON.parse(rawBody);
return payload;
}
To verify with a pinned public key instead of JWKS:
import { verifyWebhook } from "@0xkey-io/crypto";
const result = verifyWebhook({
headers: req.headers,
body: rawBody,
verificationKeys: [
{
keyId: process.env.ZEROXKEY_WEBHOOK_KEY_ID!,
publicKey: process.env.ZEROXKEY_WEBHOOK_PUBLIC_KEY!, // JWKS `x` field (base64)
},
],
maxTimestampAgeMs: 300_000,
});
Parse JSON only after signature verification. Verification must use the exact raw body bytes, not a re-serialized JSON object.
Retries and idempotency
0xkey uses the delivery backend for retry and fanout. Your receiver should:
- Return any
2xx status only after the event is durably accepted.
- Treat duplicate
eventId values as successful no-ops.
- Keep processing fast; enqueue long-running work and respond quickly.
- Prefer HTTPS endpoints with stable DNS and TLS certificates.