Webhooks
The platform posts a plain JSON body to your webhookUrl when an order reaches a final state, signed with the platform Ed25519 key. The SDK verifies the request and returns the parsed event; you never handle the headers yourself. The wire format is documented under API Reference, Payment webhook and Payout webhook.
Hand the SDK the exact bytes received. Any framework that parses JSON before your handler runs changes the bytes and breaks the digest. Disable body parsing for the webhook route or read the raw stream.
Two methods
| Verify only | Verify and parse | |
|---|---|---|
| Go | VerifyWebhook(r) ([]byte, error) | ParsePaymentWebhook(r), ParsePayoutWebhook(r) |
| JavaScript | verifyWebhook({ method, path, headers, body, rawQuery }) | parsePaymentWebhook(...), parsePayoutWebhook(...) |
| Python | verify_webhook(method=, path=, headers=, body=, raw_query=) | parse_payment_webhook(...), parse_payout_webhook(...) |
| PHP | verifyWebhook($method, $path, $headers, $body, $rawQuery = '') | parsePaymentWebhook(...), parsePayoutWebhook(...) |
| Java | verifyWebhook(method, path, headers, body, rawQuery) | parsePaymentWebhook(...), parsePayoutWebhook(...) |
Verify returns the verified raw body. Parse additionally decodes it, checks the five required fields and enforces orderType: a payout event handed to the payment parser is rejected, so a mis-routed webhook cannot update the wrong table. Use the parse methods unless you route events yourself.
Every check runs in this order: request shape, Content-Digest over the body, Webhook-Event-Id equals the body eventId, created/expires window, platform key selected by keyid, Ed25519 signature. Any failure is a webhook error; respond with 4xx and do not process the body.
Handlers
- Go
- JavaScript
- Python
- PHP
- Java
func paymentWebhook(w http.ResponseWriter, r *http.Request) {
wh, err := c.ParsePaymentWebhook(r) // reads and verifies r.Body itself
if err != nil {
w.WriteHeader(http.StatusUnauthorized)
return
}
if seen(wh.EventID) { // at-least-once delivery
w.WriteHeader(http.StatusOK)
return
}
switch wh.Status {
case deepayment.StatusSucceeded:
credit(wh.MerchantOrderNo, wh.PaidAmount)
case deepayment.StatusFailed:
fail(wh.MerchantOrderNo, wh.Failure.Msg)
}
w.WriteHeader(http.StatusOK)
}
// Express: keep the raw body for this route only
app.post('/webhook/payment', express.raw({ type: 'application/json' }), async (req, res) => {
let wh;
try {
wh = await client.parsePaymentWebhook({
method: req.method,
path: req.path,
rawQuery: req.url.split('?')[1] ?? '',
headers: req.headers,
body: req.body, // Buffer of the exact bytes
});
} catch (err) {
return res.sendStatus(401);
}
if (await seen(wh.eventId)) return res.sendStatus(200);
if (wh.status === 'SUCCEEDED') await credit(wh.merchantOrderNo, wh.paidAmount);
else if (wh.status === 'FAILED') await fail(wh.merchantOrderNo, wh.failure?.msg);
res.sendStatus(200);
});
# Flask
@app.post("/webhook/payment")
def payment_webhook():
try:
wh = client.parse_payment_webhook(
method=request.method,
path=request.path,
raw_query=request.query_string.decode(),
headers=dict(request.headers),
body=request.get_data(), # exact bytes
)
except WebhookError:
return "", 401
if seen(wh.eventId):
return "", 200
if wh.status == "SUCCEEDED":
credit(wh.merchantOrderNo, wh.paidAmount)
elif wh.status == "FAILED":
fail(wh.merchantOrderNo, wh.failure.msg if wh.failure else "")
return "", 200
try {
$wh = $client->parsePaymentWebhook(
$_SERVER['REQUEST_METHOD'],
parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH),
getallheaders(),
file_get_contents('php://input'), // exact bytes
$_SERVER['QUERY_STRING'] ?? ''
);
} catch (WebhookException $e) {
http_response_code(401);
exit;
}
if (seen($wh['eventId'])) { http_response_code(200); exit; }
if ($wh['status'] === 'SUCCEEDED') credit($wh['merchantOrderNo'], $wh['paidAmount']);
elseif ($wh['status'] === 'FAILED') fail($wh['merchantOrderNo'], $wh['failure']['msg'] ?? '');
http_response_code(200);
// Servlet
Map<String, Object> wh;
try {
wh = client.parsePaymentWebhook(
request.getMethod(),
request.getRequestURI(),
headers, // Map<String, String>
request.getInputStream().readAllBytes(), // exact bytes
request.getQueryString() == null ? "" : request.getQueryString());
} catch (DeepaymentException.Webhook e) {
response.setStatus(401);
return;
}
if (seen((String) wh.get("eventId"))) { response.setStatus(200); return; }
if ("SUCCEEDED".equals(wh.get("status"))) credit(wh);
else if ("FAILED".equals(wh.get("status"))) fail(wh);
response.setStatus(200);
The event
| Field | Payment | Payout | Meaning |
|---|---|---|---|
eventId | yes | yes | Unique per event, stable across retries. Your deduplication key. |
orderType | PAYMENT | PAYOUT | Enforced by the parse methods. |
orderNo, merchantOrderNo | yes | yes | Both numbers. |
status | yes | yes | Final state only: SUCCEEDED, FAILED, and for payments EXPIRED, CANCELED. |
currency, amount | yes | yes | Decimal strings. |
paidAmount | yes | no | What settled. |
channelTradeNo | optional | optional | Channel-side reference. |
failure | when failed | when failed | code, msg, message. |
attach | echoed | echoed |
Failures
When status is FAILED, branch on failure.msg; failure.message is free text for logs and support.
failure.msg | Meaning | Do |
|---|---|---|
ORDER_REJECTED | Risk control or business rule refused the order | Do not retry the same request |
CHANNEL_ERROR | The upstream channel failed | The order is final; create a new one if the business allows |
INSUFFICIENT_BALANCE | Payout could not be funded | Top up, then create a new payout |
Rules
- Respond 2xx only after the event is durably stored. Anything else triggers a retry. A retry is re-signed with fresh timestamps and nonce; the body and
eventIdstay the same. - Deduplicate by
eventId. Delivery is at least once. - Make state updates idempotent by
merchantOrderNo. Webhooks for different orders can arrive in any order. - When in doubt, query. A webhook that conflicts with your local state, or arrives late, is settled by querying the order.
- Keep both webhook keys during a rotation. See Configuration.