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.
Configure webhook endpoints in the dashboard under Settings -> Webhooks.
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 |
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.