Register a webhook¶
This guide sets up an HTTP endpoint that Photon calls on every status change of your transactions, and shows how to authenticate and verify each delivery.
Prerequisites
- An API key or a wallet-login JWT — Get API access
- A publicly reachable HTTP(S) endpoint that accepts JSON
POSTrequests
1. Register your endpoints¶
POST https://photon.example.com/api/user/register-webhooks authenticates with either an api-key header or Authorization: Bearer <JWT>. The body is a top-level JSON array:
| Field | Type | Required | Notes |
|---|---|---|---|
url |
string | yes | Must start with http:// or https://. |
authType |
string | yes | One of none, bearer, basic, api-key. |
secret |
string | when authType ≠ none |
Credential sent on each delivery; also the HMAC signing key. |
apiKeyVar |
string | no | For api-key: name of the header or query parameter. Default X-API-Key. |
apiKeyPlacement |
string | no | For api-key: query to send the secret as a query parameter instead of a header. |
enabled |
boolean | no | Default true. Disabled webhooks receive nothing. |
const res = await fetch("https://photon.example.com/api/user/register-webhooks", {
method: "POST",
headers: { "api-key": "YOUR_API_KEY", "Content-Type": "application/json" },
body: JSON.stringify([
{
url: "https://example.com/hooks/photon",
authType: "bearer",
secret: "whsec_a1b2c3d4",
},
]),
});
console.log(await res.json());
Registration replaces your entire webhook set — include every webhook you want to keep on each call. [] removes them all.
2. Read the response¶
Warning
This endpoint returns a bare object, not the standard Photon envelope. Secrets are never echoed back.
{
"userId": "68c275846a6ba1c9a2198a8c",
"address": "0xA7A833e6641D7901F30EaD6f27d4Ee2C9bb670a7",
"webhooks": [
{ "url": "https://example.com/hooks/photon", "authType": "bearer", "enabled": true }
]
}
3. Handle deliveries¶
Each delivery is a POST with Content-Type: application/json. The body is the bare notification JSON — the same payload the WebSocket streams, with no event wrapper:
{
"txId": "68fa3450539a3c9d28bbca33",
"chainId": 84532,
"status": "EXECUTED",
"costUSD": 0.0021,
"totalNativeTokenUsed": "630000000000",
"gasPrice": "30000000000",
"txHash": "0x9f2c41d1a3f0e8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3",
"submittedAt": "2026-07-29T10:15:00.000Z",
"updatedAt": "2026-07-29T10:15:04.500Z",
"retries": 0
}
costUSD, totalNativeTokenUsed, gasPrice, and txHash are omitted when not yet known. Authentication depends on authType:
authType |
What Photon sends |
|---|---|
none |
No auth header. |
bearer |
Authorization: Bearer <secret> |
basic |
Authorization: Basic <secret> — supply the pre-encoded credentials as the secret. |
api-key |
The secret in a header named apiKeyVar, or as a query parameter when apiKeyPlacement is query. |
4. Verify the signature¶
Whenever a secret is set, every delivery also carries X-Photon-Signature: sha256=<hex> — the HMAC-SHA256 of the raw request body, keyed with your secret. Setting authType: "none" with a secret gives you signature verification without an auth header.
import { createHmac, timingSafeEqual } from "node:crypto";
function verifySignature(rawBody: string, header: string, secret: string): boolean {
const expected =
"sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
return (
header.length === expected.length &&
timingSafeEqual(Buffer.from(header), Buffer.from(expected))
);
}
5. Respond quickly¶
Reply with any 2xx status to acknowledge a delivery. Requests time out after 10 seconds; a timeout or non-2xx response is retried with exponential backoff (2 s, 4 s, 8 s, 16 s) for up to 5 total attempts.
Related¶
- Register webhooks reference
- Register a WebSocket for streaming instead of callbacks
- Get transaction status