Webhooks
Set a callback URL on the key (or per order with callback_url) and we POST every status change
of orders placed with that key.
- Events:
order.paid,order.fulfilling,order.completed,order.failed,order.refunded—order.<status in lower case>— pluspingwhen support tests your endpoint. - Target: the order's own
callback_url, else the key's. Neither set ⇒ no webhooks. A deleted key gets none. - Callback URLs must be public
https://URLs (at most 512 characters; nolocalhost, no private, loopback or reserved IP addresses) —45212otherwise. The host is resolved again at send time, and a private address is refused.
The request
POST /hooks/energy HTTP/1.1
Content-Type: application/json
User-Agent: PayAnyEnergy-Webhook/1
X-Access-Id: ek_0123456789abcdef0123
X-Timestamp: 1760000123
X-Nonce: 9f1c0b4e6a2d4c1e8b7f3a5d2c6e1f00
X-Signature: 5b2e…
X-Event: order.completed
X-Delivery-Id: 9f1c0b4e6a2d4c1e8b7f3a5d2c6e1f00
X-Seq: 42
{"event":"order.completed","seq":42,"delivery_id":"9f1c…","created_at":"2026-10-13T08:00:05Z",
"test_mode":false,"order":{…Order…}}| Body field | Meaning |
|---|---|
event | order.<status> or ping |
seq | grows with every event of your key — see Ordering |
delivery_id | identifies the event (32 hex); the same on every retry — dedupe on it |
created_at | when the event happened |
test_mode | true for test-mode orders |
order | the full Order as it was at that moment (null for ping) |
| Header | Value |
|---|---|
X-Access-Id | the key's access id |
X-Timestamp | Unix seconds, fresh on every attempt |
X-Nonce | equals delivery_id (the same on every attempt) |
X-Signature | HMAC over the request — see Verify a webhook |
X-Event, X-Delivery-Id, X-Seq | copies of the body fields |
Acknowledge
Answer HTTP 2xx with the body ok (also accepted: success, 0, optionally
JSON-quoted, any case, surrounding whitespace ignored — or a JSON object with "code": 0).
Anything else is a failed attempt — another status, a 2xx with any other body, a redirect, or no answer within 10 seconds. Respond fast and do your work after acknowledging.
Retries
After a failed attempt n, the next one is min(30 × 2^(n−1), 3600) seconds later: 30 s, 1 min,
2 min, 4 min, … at most 1 hour between tries, 8 tries in total. After that the event is given
up and our support is alerted; support can replay it (same delivery_id).
Ordering and duplicates
- The same event can arrive more than once — dedupe on
delivery_id(X-Delivery-Id). - Events can arrive out of order.
seqgrows with every event of your key, so for one order the event with the largerseqis the newer state. Ignore an event whoseseqis lower than the last one you applied to that order. - When in doubt,
GET /v1/orders/{order_no}returns the current state.
Verify a webhook
The signature uses the same scheme as API requests, with your key's secret — also for Bearer keys:
string_to_sign = X-Timestamp + "\n" + X-Nonce + "\n" + "POST" + "\n" + <path+query of YOUR callback URL> + "\n" + <raw body>- Read the raw body bytes, before any JSON parsing.
- Check
X-Access-Idis your key and|now − X-Timestamp| ≤ 300. - Compute the HMAC with the path (and query) of your callback URL — e.g.
/hooks/energyforhttps://shop.example/hooks/energy— and compare it in constant time withX-Signature. - Skip events whose
X-Delivery-Idyou already processed (retries reuse it), then answerok.
from client import verify_webhook # docs example client, or copy the function
@app.post("/hooks/energy")
async def energy_hook(request):
body = await request.body()
if not verify_webhook(
SECRET, request.headers, body, "/hooks/energy", expected_access_id=ACCESS_ID
):
return Response(status_code=401)
event = json.loads(body)
if not already_processed(event["delivery_id"]):
handle(event) # newest state per order = largest seq
return PlainTextResponse("ok")// Express — keep the raw bytes for the signature.
app.post('/hooks/energy', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifyWebhook(SECRET, req.headers, req.body, '/hooks/energy', { expectedAccessId: ACCESS_ID })) {
return res.status(401).end()
}
const event = JSON.parse(req.body.toString('utf8'))
if (!alreadyProcessed(event.delivery_id)) handle(event)
res.type('text/plain').send('ok')
})$raw = file_get_contents('php://input');
if (!energy_verify_webhook($secret, getallheaders(), $raw, '/hooks/energy', $accessId)) {
http_response_code(401);
exit;
}
$event = json_decode($raw, true);
if (!already_processed($event['delivery_id'])) {
handle($event);
}
echo 'ok';verify_webhook / verifyWebhook / energy_verify_webhook are in the example
clients.