Webhooks
Aura sends a webhook to your endpoint when a purchase reaches a final state, so you do not have to poll. Your technical contact sets up your endpoint URL and gives you its signing secret.
Events
Section titled “Events”| Event | Sent when |
|---|---|
purchase.successful |
The network confirmed the bundle was credited. You were charged. |
purchase.failed |
The purchase ended without a credit. Nothing was charged. |
purchase.reversed |
The network reversed a credit it had confirmed. You were refunded. |
There is no event for pending: the response to your purchase request already tells you that.
What a webhook looks like
Section titled “What a webhook looks like”POST /webhooks/aura HTTP/1.1Content-Type: application/jsonAura-Event-Id: evt_01JA80C4M2T7R9F3H6K8P1D5QSAura-Event-Type: purchase.successfulAura-Timestamp: 1760260476Aura-Signature: v1=5f2b0c7d9e8a41b3c6d0e9f2a7b4c1d8e3f6a9b2c5d8e1f4a7b0c3d6e9f2a5b8
{ "id": "evt_01JA80C4M2T7R9F3H6K8P1D5QS", "object": "event", "type": "purchase.successful", "created_at": "2026-10-12T09:14:31Z", "data": { "object": { "id": "pur_01JA7ZK3Q9W2D8M5X4N6B1C0VE", "object": "purchase", "reference": "order-10293", "status": "successful", … } }}data.object is the whole purchase, exactly as Get a purchase
would return it at that moment. The full schemas are in the
API reference.
Verify the signature
Section titled “Verify the signature”Every webhook is signed. Check the signature before you trust anything in it:
- Take
Aura-Timestampand the raw request body, before any JSON parsing. - Compute the HMAC-SHA256 of
{timestamp}.{raw body}with your webhook secret, as lowercase hex. - Compare it, in constant time, with each
v1=value inAura-Signature. During a secret rotation there are two, separated by a comma; accept the webhook if either matches. - Reject the webhook if the timestamp is more than five minutes from your clock. That stops an old webhook being replayed.
import crypto from 'node:crypto';import express from 'express';
const SECRET = process.env.AURA_WEBHOOK_SECRET;const TOLERANCE_SECONDS = 300;
export function isValidAuraWebhook(rawBody, timestamp, signatureHeader) { const age = Math.abs(Date.now() / 1000 - Number(timestamp)); if (!timestamp || !Number.isFinite(age) || age > TOLERANCE_SECONDS) return false; const expected = crypto.createHmac('sha256', SECRET).update(`${timestamp}.`).update(rawBody).digest('hex'); return (signatureHeader ?? '').split(',').some((part) => { const [version, signature] = part.trim().split('='); return ( version === 'v1' && signature?.length === expected.length && crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)) ); });}
const app = express();
app.post('/webhooks/aura', express.raw({ type: 'application/json' }), async (req, res) => { if (!isValidAuraWebhook(req.body, req.get('Aura-Timestamp'), req.get('Aura-Signature'))) { return res.sendStatus(400); } const event = JSON.parse(req.body); await saveEventOnce(event); // Store by event.id; ignore IDs you have already stored. res.sendStatus(200);});import hashlib, hmac, os, timefrom flask import Flask, abort, request
SECRET = os.environ["AURA_WEBHOOK_SECRET"].encode()TOLERANCE_SECONDS = 300
def is_valid_aura_webhook(raw_body: bytes, timestamp: str, signature_header: str) -> bool: try: if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS: return False except ValueError: return False expected = hmac.new(SECRET, timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest() return any( part.strip().startswith("v1=") and hmac.compare_digest(part.strip()[3:], expected) for part in signature_header.split(",") )
app = Flask(__name__)
@app.post("/webhooks/aura")def aura_webhook(): raw_body = request.get_data() timestamp = request.headers.get("Aura-Timestamp", "") signature = request.headers.get("Aura-Signature", "") if not is_valid_aura_webhook(raw_body, timestamp, signature): abort(400) save_event_once(request.get_json()) # Store by event id; ignore IDs you have already stored. return "", 200<?phpfunction is_valid_aura_webhook(string $rawBody, string $timestamp, string $signatureHeader, string $secret): bool{ if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) { return false; } $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret); foreach (explode(',', $signatureHeader) as $part) { $part = trim($part); if (str_starts_with($part, 'v1=') && hash_equals($expected, substr($part, 3))) { return true; } } return false;}
$rawBody = file_get_contents('php://input');$timestamp = $_SERVER['HTTP_AURA_TIMESTAMP'] ?? '';$signature = $_SERVER['HTTP_AURA_SIGNATURE'] ?? '';
if (!is_valid_aura_webhook($rawBody, $timestamp, $signature, getenv('AURA_WEBHOOK_SECRET'))) { http_response_code(400); exit;}
save_event_once(json_decode($rawBody, true)); // Store by event id; ignore IDs you have already stored.http_response_code(200);Acknowledge quickly
Section titled “Acknowledge quickly”Return any 2xx status within 10 seconds. Store the event first, then do slow work, such as messaging the
customer, afterwards. Anything else, including a timeout or a redirect, counts as a failed delivery.
Retries
Section titled “Retries”If a delivery fails, Aura tries again: 1 minute, 5 minutes, 30 minutes, 2 hours, 8 hours and 24 hours after the
first attempt. Each attempt is signed again with a new timestamp and carries the same Aura-Event-Id.
If every attempt fails, the event is not sent again. Nothing is lost: the purchase’s final state is always there to
look up. If your endpoint was down, look up the purchases you
are still holding as pending once it is back.
Duplicates and order
Section titled “Duplicates and order”- Delivered at least once. The same event can arrive more than once. Record each
Aura-Event-Idyou process and ignore repeats. - Not guaranteed in order. A
purchase.reversedcould, rarely, arrive before thepurchase.successfulit follows. A purchase only ever moves forward, frompendingtosuccessfulorfailed, and fromsuccessfultoreversed. Never move a purchase backwards in your records because of a late event.
Your endpoint
Section titled “Your endpoint”- It must use HTTPS with a valid certificate.
- It must accept a
POSTwith a JSON body. - Your technical contact can send test events to it, and the sandbox sends real webhooks for test purchases.
© 2026 Aura Mobile Communications Ltd ·poweredbyaura.com ·Privacy ·Terms