Webhooks
Overview
Webhooks push event notifications to your server when asynchronous state changes occur. Rather than polling the API to check payment status, configure a webhook endpoint and let ElasticPay notify you.
Webhook endpoints are configured by ElasticPay — there is no self-serve webhook screen in the dashboard. To add or change one, contact support with:
- the endpoint URL (publicly reachable, HTTPS, not
localhost) - the event types you want to receive
- whether it is for your sandbox or live account
Sandbox and live accounts have separate subscriptions, each with its own signing secret. Sandbox events are only delivered to sandbox endpoints, and live events only to live endpoints. Secret rotation also goes through support.
Once events are flowing, the delivery status of an individual event is shown in the Events panel on the payment intent, payment plan, or customer detail page.
Event envelope
Every webhook event has the same structure:
{ "version": "1", "event_id": "evt_0abc123def456ghi789jkl", "event_type": "payment_intent.succeeded", "biller_id": "biller_abc123", "livemode": false, "emitted_at": "2025-01-15T10:05:00Z", "data": { "id": "pi_0abc123def456ghi789jkl012mn", "status": "succeeded", "amount": 5000, "currency": "AUD" }}| Field | Description |
|---|---|
version | Envelope schema version |
event_id | Unique event identifier — use for deduplication |
event_type | Event name (see table below) |
biller_id | Account that generated the event |
livemode | true for live events, false for sandbox |
emitted_at | ISO 8601 timestamp |
data | The resource at the time of the event |
Event types
| Event | Description |
|---|---|
payment_intent.succeeded | Payment completed successfully |
payment_intent.failed | Payment declined or failed |
payment_intent.processing | Submitted to PSP, awaiting result |
payment_intent.canceled | Payment intent was canceled |
payment_intent.refunded | Full refund succeeded |
payment_intent.partially_refunded | Partial refund succeeded |
payment_intent.refund_failed | Refund attempt failed |
setup_intent.succeeded | Payment method saved successfully |
setup_intent.failed | Setup intent failed |
test_clock.created | Sandbox test clock created |
test_clock.advancing | Test clock advance accepted (includes target_time) |
test_clock.ready | Test clock advance completed (includes effects_manifest) |
test_clock.deleted | Test clock deleted, manually or at expiry |
Verifying signatures
Every webhook request includes an X-Webhook-Signature header. Verify it to confirm the request came from ElasticPay.
The signature is computed as v1=HMAC-SHA256(secret, "<timestamp>.<raw_body>") where <timestamp> is the value of the X-Webhook-Timestamp header.
| Header | Description |
|---|---|
X-Webhook-Signature | v1=<hex HMAC-SHA256> |
X-Webhook-Timestamp | ISO 8601 timestamp of when the event was signed |
X-Webhook-Key-Id | Identifies which signing key was used |
Reject requests where the timestamp is more than 5 minutes from your server clock to prevent replay attacks.
# Webhook requests include these headers:## X-Webhook-Signature v1=<hex HMAC-SHA256># X-Webhook-Timestamp ISO 8601 timestamp# X-Webhook-Key-Id signing key identifier## The signature is computed as:# HMAC-SHA256(secret, "<timestamp>.<raw_body>")## Reject requests where the timestamp is more than 5 minutes# from your server clock to prevent replay attacks.import { createHmac, timingSafeEqual } from "crypto";
function verifyWebhookSignature( rawBody: string, signature: string, timestamp: string, secret: string): boolean { const payload = `${timestamp}.${rawBody}`; const expected = `v1=${createHmac("sha256", secret).update(payload).digest("hex")}`; return timingSafeEqual(Buffer.from(expected), Buffer.from(signature));}
// Express handlerapp.post("/webhooks/elasticpay", (req, res) => { const signature = req.headers["x-webhook-signature"] as string; const timestamp = req.headers["x-webhook-timestamp"] as string; const isValid = verifyWebhookSignature( JSON.stringify(req.body), signature, timestamp, process.env.WEBHOOK_SECRET! ); if (!isValid) return res.status(401).send("Invalid signature"); res.status(200).send("OK"); // Process the event asynchronously});import hmacimport hashlibimport osfrom flask import Flask, request, abort
app = Flask(__name__)
def verify_webhook_signature(raw_body: bytes, signature: str, timestamp: str) -> bool: secret = os.environ["WEBHOOK_SECRET"].encode() payload = f"{timestamp}.{raw_body.decode()}".encode() expected = "v1=" + hmac.new(secret, payload, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, signature)
@app.post("/webhooks/elasticpay")def webhook_handler(): signature = request.headers.get("X-Webhook-Signature", "") timestamp = request.headers.get("X-Webhook-Timestamp", "") if not verify_webhook_signature(request.get_data(), signature, timestamp): abort(401) # Respond 200 immediately; process asynchronously return "", 200<?php
function verifyWebhookSignature( string $rawBody, string $signature, string $timestamp, string $secret): bool { $payload = "{$timestamp}.{$rawBody}"; $expected = "v1=" . hash_hmac("sha256", $payload, $secret); return hash_equals($expected, $signature);}
$rawBody = file_get_contents("php://input");$signature = $_SERVER["HTTP_X_WEBHOOK_SIGNATURE"] ?? "";$timestamp = $_SERVER["HTTP_X_WEBHOOK_TIMESTAMP"] ?? "";$secret = getenv("WEBHOOK_SECRET");
if (!verifyWebhookSignature($rawBody, $signature, $timestamp, $secret)) { http_response_code(401); exit("Invalid signature");}
http_response_code(200);// Process event asynchronouslyrequire "openssl"require "rack"
def verify_webhook_signature(raw_body, signature, timestamp, secret) payload = "#{timestamp}.#{raw_body}" expected = "v1=#{OpenSSL::HMAC.hexdigest('SHA256', secret, payload)}" Rack::Utils.secure_compare(expected, signature)end
# Sinatra handler examplepost "/webhooks/elasticpay" do raw_body = request.body.read signature = request.env["HTTP_X_WEBHOOK_SIGNATURE"].to_s timestamp = request.env["HTTP_X_WEBHOOK_TIMESTAMP"].to_s secret = ENV["WEBHOOK_SECRET"]
halt 401 unless verify_webhook_signature(raw_body, signature, timestamp, secret)
status 200 # Process event asynchronouslyendusing System.Security.Cryptography;using System.Text;
bool VerifyWebhookSignature( string rawBody, string signature, string timestamp, string secret){ var payload = Encoding.UTF8.GetBytes($"{timestamp}.{rawBody}"); var key = Encoding.UTF8.GetBytes(secret); var hash = HMACSHA256.HashData(key, payload); var expected = "v1=" + Convert.ToHexString(hash).ToLowerInvariant(); return CryptographicOperations.FixedTimeEquals( Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(signature));}
// ASP.NET Core minimal APIapp.MapPost("/webhooks/elasticpay", async (HttpRequest req) =>{ using var reader = new StreamReader(req.Body); var rawBody = await reader.ReadToEndAsync(); var signature = req.Headers["X-Webhook-Signature"].ToString(); var timestamp = req.Headers["X-Webhook-Timestamp"].ToString(); var secret = Environment.GetEnvironmentVariable("WEBHOOK_SECRET")!;
if (!VerifyWebhookSignature(rawBody, signature, timestamp, secret)) return Results.Unauthorized();
return Results.Ok(); // Process event asynchronously});Best practices
- Respond 200 immediately. Process events asynchronously — do not make slow API calls inside the handler.
- Make handlers idempotent. Events may be delivered more than once. Use
event_idto deduplicate. - Verify
livemode. Match the flag to your environment to avoid processing test events in production. - Expect retries. If your endpoint returns a non-2xx status, ElasticPay retries delivery with exponential backoff.