Pay AGL
Merchant REST API

Pay AGL API Documentation

Create payment links, collect UPI pay-ins, send bank payouts, check wallet balances, and receive signed webhooks from one merchant integration.

Quickstart for merchants

  1. Log in to the merchant dashboard.
  2. Open API & Webhooks and copy your live API key. Keep your API secret private; it is used to verify Pay AGL webhooks.
  3. Submit your server IP for approval in the IP whitelist section if your account uses whitelist enforcement.
  4. Configure pay-in and payout callback URLs.
  5. Use the examples below from your backend server, never from browser JavaScript.
Base URL: https://payagl.com. All request and response bodies are JSON. Amounts are decimal numbers in your merchant currency, usually INR for Indian UPI/IMPS rails.

Authentication

Send your live merchant API key on every API request.

x-api-key: pk_live_your_merchant_key
Content-Type: application/json
StatusMeaning
401Invalid API key.
403Merchant inactive, IP not whitelisted, or payout fee not configured.
429Rate limited.

POSTCreate payment

Creates a production pay-in through the active Pay AGL route and returns a hosted checkout URL plus PSP-issued UPI deeplinks. Pay AGL never returns a placeholder VPA. order_id is required and idempotent per merchant: retrying the exact same request returns the existing transaction; reusing it with different amount, currency, or destination returns 409. Callback and post-payment redirect URLs must use public https:// addresses. The request currency must match the merchant account currency.

curl -X POST https://payagl.com/api/payment/create \
  -H "x-api-key: pk_live_your_merchant_key" \
  -H "Content-Type: application/json" \
  -d '{
    "order_id": "ORD-10001",
    "amount": 1500.00,
    "currency": "INR",
    "customer_name": "Ramesh Kumar",
    "customer_email": "ramesh@example.com",
    "customer_phone": "9999999999",
    "description": "Order #10001",
    "callback_url": "https://merchant.example/webhooks/payin",
    "redirect_url_after": "https://merchant.example/thank-you"
  }'
{
  "txn_id": "TXN...",
  "order_id": "ORD-10001",
  "status": "PENDING",
  "amount": 1500,
  "currency": "INR",
  "redirect_url": "/api/payment/redirect/TXN...",
  "checkout_url": "/checkout.html?id=TXN...",
  "payment_links": {
    "ready": true,
    "upi_deeplink": "upi://pay?...",
    "phonepe_intent": "phonepe://pay?...",
    "gpay_intent": "tez://upi/pay?...",
    "paytm_intent": "paytmmp://pay?...",
    "qr_image_url": null,
    "expires_at": "2026-06-23T...",
    "error": null
  }
}

Hosted vs custom checkout

  • Hosted checkout: redirect the customer to checkout_url.
  • Custom checkout: open the correct value from payment_links for PhonePe, GPay, Paytm, or generic UPI.

GETPayment status

Check a payment using either the PayAGL txn_id or your submitted order_id. The lookup is restricted to the merchant authenticated by the API key.

curl https://payagl.com/api/payment/status/ORD-10001   -H "x-api-key: pk_live_your_merchant_key"

Path parameter: txn_id_or_order_id — pass the exact transaction ID or order ID (case-sensitive).

{
  "txn_id": "TXN...",
  "order_id": "ORD-10001",
  "merchant_id": "PAGLMER000080",
  "amount": 1500,
  "currency": "INR",
  "status": "SUCCESS",
  "utr": "123456789012",
  "completed_at": 1782220000000
}

Payment statuses: PENDING, SUCCESS, FAILED. utr is null until available.

GETCollection UPI / payment receipt

Look up your own pay-in by exact transaction ID or order ID. Uses the latest stored transaction status; this endpoint does not query the provider or verify that a UPI ID is active.

curl https://payagl.com/api/payment/upi/ORD-10001 -H "x-api-key: pk_live_your_merchant_key"

Successful payment (UPI ID is omitted):

{"txn_id":"TXN...","order_id":"ORD-10001","status":"SUCCESS","utr":"123456789012"}

Payment not received (UTR is omitted):

{"txn_id":"TXN...","order_id":"ORD-10001","status":"PENDING","upi_id":"collection@bank","message":"Payment not received"}

Failed, processing, and expired transactions retain their actual status. When no usable collection UPI deeplink was stored, upi_id is null with upi_message: "UPI ID unavailable". A successful payment whose UTR has not arrived returns utr: null. Unknown IDs and other merchants' IDs return HTTP 404. Standard API key, IP, and enabled request-signing policies apply.

POSTVerify payment or stored UPI ID

/api/payment/verify accepts a transaction ID or merchant order ID as reference. The order_id alias and UTR-only lookup are also supported. All lookups are restricted to the authenticated merchant's pay-ins. Existing GET status and UPI endpoints remain unchanged.

curl https://payagl.com/api/payment/verify -X POST -H "x-api-key: pk_live_your_merchant_key" -H "Content-Type: application/json" -d '{"reference":"ORD-10001"}'

Successful response (HTTP 200):

{"txn_id":"TXN...","order_id":"ORD-10001","status":"SUCCESS","upi_id":"collection@bank","utr":"123456789012","upi_assigned":true}

Pending and failed examples (HTTP 200):

{"txn_id":"TXN...","order_id":"ORD-10001","status":"PENDING","upi_id":"collection@bank","utr":null,"upi_assigned":true}
{"txn_id":"TXN...","order_id":"ORD-10002","status":"FAILED","upi_id":null,"utr":null,"upi_assigned":false}

UPI ID-only verification (no reference, order ID or UTR required):

curl https://payagl.com/api/payment/verify -X POST -H "x-api-key: pk_live_your_merchant_key" -H "Content-Type: application/json" -d '{"upi_id":"example@bank"}'
{"upi_id":"example@bank","status":"MATCH","verification_scope":"MERCHANT_STORED_RECORDS","live_bank_verified":false}

All three results return HTTP 200: MATCH means the normalized UPI ID matches a saved collection payee in your authorized records; NOT_FOUND means usable authorized evidence exists but contains no match; UNVERIFIED means no usable authorized evidence is available. These results do not establish payment receipt, current assignment, account activity, or live bank verification. NOT_FOUND does not mean the UPI ID is invalid at a bank.

Evidence is limited to your own pay-ins and saved payment links with a valid provider UPI payee URI, plus active pay-in pipelines explicitly assigned to you through current routing bands (positive allocations) or your pay-in pipeline setting. Pipeline records must contain a saved payee URI or an explicit upi_verified: true flag for their configured upi_id. Country compatibility is required. Global default pipelines, other merchants' records, payer VPAs, bare unverified configuration, and arbitrary provider response fields are excluded. No transaction, pipeline, credential, or other merchant details are returned. UPI ID-only lookup is case insensitive, validates syntax, and never changes stored records.

This endpoint uses the existing API key authentication, rate limit (120 authenticated requests per source IP per 10 seconds), IP restrictions, and enabled request signing. Enhanced merchants must sign the exact request body and path as usual. HTTP 400 indicates malformed input, 401/403 authentication or policy rejection, and 429 rate limiting. When any reference, order ID, or UTR is supplied, the existing transaction lookup response and matching rules below are unchanged.

Alternative request bodies:

{"order_id":"ORD-10001"}
{"utr":"123456789012"}
{"reference":"ORD-10001","upi_id":"collection@bank"}

Optional upi_id adds upi_matches: true or false to confirm whether that payee was assigned to this transaction. Assignment is derived only from the transaction's saved provider UPI URI. Missing UPI or UTR returns null. Status reflects the latest stored state, including processing or expired; this endpoint does not poll the provider or test UPI account activity. UPI assignment alone does not confirm payment.

Unknown references, other merchants' transactions, and conflicting supplied identifiers return HTTP 404 with {"detail":"Transaction not found"}. Missing or invalid identifiers return HTTP 400. When reference and UTR are supplied together, both must match. Standard API authentication, IP restrictions, and enabled request signing apply.

GETWallet balance

Returns pay-in wallet and payout wallet balances. API payouts debit the payout wallet.

curl https://payagl.com/api/wallet/balance \
  -H "x-api-key: pk_live_your_merchant_key"
{
  "merchant_id": "PAGLMER000080",
  "currency": "INR",
  "payin_wallet": {
    "balance": 150000,
    "pending_settlement": 5000,
    "pending_topup": 0,
    "pending_total": 5000,
    "available": 145000
  },
  "payout_wallet": { "balance": 80000, "available": 80000 },
  "balance": 150000,
  "payout_wallet_balance": 80000,
  "available": 145000
}

POSTCreate API payout

Sends a bank payout (IMPS or NEFT) from your payout wallet to a beneficiary account. Your merchant account must have API payout commission configured. total_debit = amount + payout_commission. order_id is idempotent per merchant. The current platform minimum is 150; merchant and route limits may be stricter and are enforced at request time.

curl -X POST https://payagl.com/api/payout/create \
  -H "x-api-key: pk_live_your_merchant_key" \
  -H "Content-Type: application/json" \
  -d '{
    "order_id": "WD-10001",
    "amount": 500.00,
    "method": "IMPS",
    "beneficiary_name": "Ramesh Kumar",
    "beneficiary_bank": "HDFC Bank",
    "beneficiary_mobile": "9876543210",
    "beneficiary_email": "ramesh@example.com",
    "account_number": "123456789012",
    "ifsc": "HDFC0001234",
    "callback_url": "https://merchant.example/webhooks/payout"
  }'

Submit payout requests to PayAGL as application/json. Include the actual bank name in beneficiary_bank and accurate beneficiary contact details. Use a 10-digit Indian mobile number; a country-code prefix is normalized to the last 10 digits for the current payout route. PayAGL handles conversion to the provider request format. callback_url is your PayAGL payout status webhook.

Required fields

FieldDescription
order_idYour required unique reference. Exact retries are idempotent; mismatched reuse returns 409.
amountPayout amount. Minimum 150.
beneficiary_nameAccount holder name.
account_numberBeneficiary bank account number.
ifscBeneficiary bank IFSC code.

Optional fields

FieldDescription
methodIMPS (default) or NEFT.
callback_urlOptional per-request payout webhook override. Must be a public https:// URL.
beneficiary_email, beneficiary_mobileBeneficiary contact details.
beneficiary_bankRecommended: actual bank name, for example HDFC Bank. An IFSC code or branch value does not replace the bank name.
beneficiary_branchOptional branch name/address. This field is not used as the bank name.
beneficiary_address, beneficiary_pincodeBeneficiary address and pincode.
notePayout note/narration.
{
  "payout_id": "PAYOUT...",
  "order_id": "WD-10001",
  "merchant_id": "PAGLMER000080",
  "amount": 500,
  "status": "PROCESSING",
  "payout_commission": 10,
  "total_debit": 510,
  "provider_ref": "PAYOUT...",
  "utr": ""
}

Payouts return PROCESSING first; the final SUCCESS/FAILED state is delivered to your callback_url webhook (and reflected in Payout status). On failure, the full total_debit is refunded to your payout wallet.

GETPayout status

curl https://payagl.com/api/payout/status/PAYOUT... \
  -H "x-api-key: pk_live_your_merchant_key"
{
  "payout_id": "PAYOUT...",
  "order_id": "WD-10001",
  "status": "SUCCESS",
  "amount": 500,
  "payout_commission": 10,
  "total_debit": 510,
  "provider_ref": "PAYOUT...",
  "utr": "123456789012"
}

Payout statuses: PROCESSING, SUCCESS, FAILED.

Production routing and checkout

Recommended: Send customers to the returned checkout_url. Pay AGL selects the active processor and supplies only a real PSP-issued UPI URI. Processor names, credentials, routing rules, and internal callback domains are never exposed to customers.
  • Do not generate, cache, or substitute your own UPI VPA or QR code.
  • Treat PENDING as non-final. Credit an order only after SUCCESS from a signed webhook or the status API.
  • Use a unique order_id for every order. Retrying the same ID is idempotent.
  • Store amount, IDs, status, UTR, and webhook delivery ID for reconciliation.
  • Keep API keys server-side and whitelist only fixed production IP addresses.

Webhooks from Pay AGL

Pay AGL sends webhooks to your saved callback URL or the per-request callback_url. Verify the signature against the exact raw request body using your merchant API secret.

POST https://merchant.example/webhooks/payin
Content-Type: application/json
X-PayAGL-Signature: hex_hmac_sha256(api_secret, raw_body)
X-PayAGL-Event: payment.pending | payment.success | payment.failed
X-PayAGL-Delivery-Id: uuid

Payment webhook body

{
  "txn_id": "TXN...",
  "order_id": "ORD-10001",
  "merchant_id": "PAGLMER000080",
  "amount": 1500,
  "currency": "INR",
  "status": "SUCCESS",
  "gateway_ref": "...",
  "utr": "123456789012",
  "completed_at": 1782220000000
}

Payout webhook body

{
  "payout_id": "PAYOUT...",
  "order_id": "WD-10001",
  "merchant_id": "PAGLMER000080",
  "amount": 500,
  "status": "SUCCESS",
  "utr": "123456789012",
  "payout_commission": 10,
  "total_debit": 510,
  "completed_at": "2026-06-23T..."
}

Node.js verification

const crypto = require("crypto");

function verifyPayAGL(rawBody, signature, apiSecret) {
  const expected = crypto
    .createHmac("sha256", apiSecret)
    .update(rawBody)
    .digest("hex");
  return typeof signature === "string" && /^[a-f0-9]{64}$/.test(signature) &&
    crypto.timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(signature, "hex"));
}

POSTSupport tickets

Raise and track support tickets directly from your own dashboard or server using your API key. Tickets appear in the Pay AGL support console and our team replies there.

Create a ticket

curl -X POST https://payagl.com/api/support/tickets \
  -H "x-api-key: pk_live_your_merchant_key" \
  -H "Content-Type: application/json" \
  -d '{
    "subject": "Payout WD-10001 not credited",
    "category": "Payouts",
    "priority": "high",
    "related_txn": "WD-10001",
    "message": "Customer says the payout has not arrived after 2 hours."
  }'
{
  "ticket_id": "tk_ab12cd",
  "subject": "Payout WD-10001 not credited",
  "category": "Payouts",
  "priority": "high",
  "status": "open",
  "related_txn": "WD-10001",
  "created_at": "2026-06-25T...",
  "updated_at": "2026-06-25T...",
  "messages": [ { "by": "merchant", "at": "2026-06-25T...", "text": "Customer says..." } ]
}

Categories: General, Payments / Pay-in, Payouts, Settlements, API / Integration, KYC / Account, Dispute / Chargeback, Other. Priorities: low, normal, high, urgent.

GET List your tickets

curl "https://payagl.com/api/support/tickets?status=open" \
  -H "x-api-key: pk_live_your_merchant_key"

GET Fetch one ticket

curl https://payagl.com/api/support/tickets/tk_ab12cd \
  -H "x-api-key: pk_live_your_merchant_key"

Reply to a ticket

curl -X POST https://payagl.com/api/support/tickets/tk_ab12cd/reply \
  -H "x-api-key: pk_live_your_merchant_key" \
  -H "Content-Type: application/json" \
  -d '{ "message": "Any update on this?" }'

Ticket statuses: open, pending (awaiting your reply), resolved, closed. Each message has by = merchant or support. Internal staff notes are never returned.

Integration assistance: @payagll on Telegram.

Error responses

Typical error body:

{ "detail": "message or validation details" }
HTTPMeaning
400Bad request, insufficient balance, or invalid business state.
401Invalid API key.
403IP not whitelisted, merchant inactive, or payout fee not configured.
404Transaction or payout not found for this merchant.
409The same order_id was reused with different payment or payout details, or the request is already processing.
422Validation error, missing fields, invalid amount or IFSC.
429Rate limited.
502Upstream gateway rejected the payout/payment.
503No active payment route is available for the requested country, rail, or amount.

Enhanced Security

Enhanced security is enabled per merchant after verified integration readiness and separate activation approval. Existing merchants retain their current API-key authentication and webhook format. No production merchant is enabled by this development change.

Authentication headers and request signatures (v1)

x-api-key: MERCHANT_API_KEY
X-PayAGL-Timestamp: UNIX_SECONDS
X-PayAGL-Nonce: UNIQUE_RANDOM_NONCE
X-PayAGL-Signature: LOWERCASE_HEX_HMAC_SHA256

Use HTTPS only. Use the dedicated request-signing secret, never the API key or webhook API secret. A provisioned secret is a 64-character string; use its UTF-8 text bytes as the HMAC key, without hexadecimal decoding. The API key identifies your merchant; headers remain confidential under TLS and must be redacted from application, proxy and tracing logs.

UPPERCASE_HTTP_METHOD
CANONICAL_PATH_AND_QUERY
TIMESTAMP
NONCE
LOWERCASE_HEX_SHA256_OF_RAW_BODY

Join the five lines with ASCII LF (\n), without a trailing newline. Encode this string as UTF-8 and calculate HMAC-SHA256. Output exactly 64 lowercase hexadecimal characters. Timestamp is a ten-digit integer Unix timestamp in seconds. Nonce is 16–128 characters from A–Z, a–z, 0–9, underscore and hyphen; generate at least 128 random bits. Both timestamp and nonce are signed. Accepted clock skew is at most five minutes, configurable downward by PayAGL.

Canonicalization

Every submitted field is protected, including amounts, merchant order IDs, beneficiary names, bank accounts, IFSC and callback URLs. Authentication finishes before order creation, provider requests or wallet debits. A nonce is reserved atomically in shared MongoDB storage; an unavailable store fails closed. Retry an order with the same order ID but a fresh nonce and timestamp; existing order idempotency still applies.

Pay-in and payout signing examples

POST /api/payment/create
{"amount":500,"order_id":"synthetic-payin"}

POST /api/payout/create
{"amount":500,"order_id":"synthetic-payout","beneficiary_name":"Synthetic","account_number":"12345678","ifsc":"HDFC0000001"}

These are synthetic examples, not production credentials or destinations. Add your verified callback URL only when required; otherwise PayAGL uses the approved saved default.

IP and callback requirements

Enhanced Security requires at least one administrator-verified source IP/CIDR and approved exact HTTPS callback URLs. Headers claiming an IP are accepted only from configured reverse proxies. Enhanced whitelist checks have no loopback bypass. An empty verified allowlist fails closed.

Per-request callback overrides must exactly match an approved URL after standard URL serialization. Scheme, host, port, path and query are part of approval. URL credentials and fragments are forbidden; enhanced callbacks use HTTPS port 443. PayAGL checks DNS answers before accepting orders and again before delivery, blocks internal/metadata/special addresses and pins delivery to a validated address. Redirects are never followed; a 3xx webhook response is a failed attempt. A changed destination requires administrator approval.

Enhanced webhook verification (v1)

X-PayAGL-Timestamp: UNIX_SECONDS
X-PayAGL-Delivery-ID: STABLE_EVENT_ID
X-PayAGL-Signature-Version: v1
X-PayAGL-Signature: hex_hmac_sha256(webhook_api_secret,
    UTF8(timestamp + "\n" + delivery_id + "\n") + raw_body_bytes)

The webhook secret remains the existing API secret, preserving the passed callback-signature finding. It is separate from the new request secret. Legacy merchants continue receiving HMAC-SHA256(api_secret, raw_body) without timestamp/version headers. Webhook payloads are unchanged.

Check the version, timestamp (within five minutes), delivery ID and constant-time signature before acting. Atomically store delivery IDs in durable merchant-scoped storage and process the business event idempotently by transaction/order ID. Return 2xx for a previously completed delivery. Couple recording and business processing in a transaction or reliable inbox so a crash cannot lose the event. Retain deduplication records through your event/reconciliation retention period; retries carry the same ID and freshly signed timestamp. PayAGL does not introduce an automatic retry scheduler in this upgrade. Re-sending the same event and exact raw payload preserves its ID.

Secret provisioning and rotation

Request secrets are provisioned once over HTTPS, encrypted at rest with a server-managed encryption key, and omitted from subsequent merchant responses. Rotation accepts the previous secret for at most 24 hours (configurable down to zero); use fresh nonces across both versions. Transfer secrets through an approved secure channel and store them in a secret manager. Existing API-key/webhook-secret regeneration remains unchanged and has no newly introduced overlap period.

Error codes and troubleshooting

StatusCodeAction
401SIGNATURE_REQUIRED / INVALID_SIGNATURE_HEADERS / SIGNATURE_INVALIDCheck the four headers, dedicated secret, exact transmitted bytes and canonical target.
401TIMESTAMP_EXPIRED / SIGNATURE_VERSION_UNSUPPORTEDSynchronize clocks; use Unix seconds and request signing v1.
403HTTPS_REQUIRED / IP_NOT_ALLOWEDUse HTTPS and the verified source IP; check proxy trust configuration.
409NONCE_REPLAYEDUse a new nonce and timestamp for an idempotent order retry.
400 / 415INVALID_REQUEST_TARGET / INVALID_JSON / JSON_REQUIREDUse an unambiguous target and a JSON object.
422CALLBACK_URL_REQUIRED / CALLBACK_URL_REJECTED / CALLBACK_DNS_REJECTEDVerify the approved default/override and public DNS answers.
503REPLAY_STORE_UNAVAILABLE / SIGNING_SECRET_UNAVAILABLE / SECURITY_KEY_UNAVAILABLEContact PayAGL; enforcement fails closed. Do not change payment instructions to work around it.

Invalid API keys retain the existing 401 response. Existing payment validation and idempotency errors remain unchanged.

Runnable integration helpers

Download Node.js, PHP 8+, Python 3, or the Postman collection. Each helper includes request signing and webhook signature/timestamp verification. Application code must supply atomic durable webhook deduplication and business idempotency.

Node.js signing and webhook verification
'use strict';
const crypto = require('node:crypto');
function fail(code) { throw new Error(code); }
const encode = s => encodeURIComponent(s).replace(/[!'()*]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase());
function canonicalTarget(target) {
  if (typeof target !== 'string' || !target.startsWith('/') || target.startsWith('//') || /[#\\\x00-\x20]/.test(target)) fail('INVALID_REQUEST_TARGET', 400);
  const [path, ...queryParts] = target.split('?');
  if (queryParts.length > 1) fail('INVALID_REQUEST_TARGET', 400);
  let segments;
  try { segments = path.split('/').map(s => decodeURIComponent(s)); } catch (_) { fail('INVALID_REQUEST_TARGET', 400); }
  if (segments.some(s => s === '.' || s === '..' || /[/\\\x00-\x20]/.test(s)) || path.includes('//')) fail('INVALID_REQUEST_TARGET', 400);
  const normalizedPath = segments.map(encode).join('/');
  const pairs = [];
  if (queryParts[0]) for (const part of queryParts[0].split('&')) {
    const pos = part.indexOf('=');
    try {
      const key = decodeURIComponent((pos < 0 ? part : part.slice(0, pos)).replace(/\+/g, ' '));
      const value = decodeURIComponent((pos < 0 ? '' : part.slice(pos + 1)).replace(/\+/g, ' '));
      pairs.push([encode(key), encode(value)]);
    } catch (_) { fail('INVALID_REQUEST_TARGET', 400); }
  }
  pairs.sort((a,b) => a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0);
  return normalizedPath + (pairs.length ? '?' + pairs.map(p => p.join('=')).join('&') : '');
}
function canonical(method, target, timestamp, nonce, body) {
  return [method.toUpperCase(), canonicalTarget(target), timestamp, nonce,
    crypto.createHash('sha256').update(body).digest('hex')].join('\n');
}
const sign = (secret, method, target, timestamp, nonce, body) => crypto.createHmac('sha256', secret)
  .update(canonical(method, target, timestamp, nonce, body), 'utf8').digest('hex');
function equalHex(a, b) {
  return typeof a === 'string' && /^[a-f0-9]{64}$/.test(a) && crypto.timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex'));
}
async function verifyWebhook(secret, rawBody, h, reserveDelivery, now = Date.now()) {
  const t = h['x-payagl-timestamp'], id = h['x-payagl-delivery-id'];
  if (h['x-payagl-signature-version'] !== 'v1' || !/^\d{10}$/.test(t || '') || !/^[A-Za-z0-9_-]{16,128}$/.test(id || '') || Math.abs(now/1000-Number(t)) > 300) throw new Error('Invalid or expired webhook');
  const expected = crypto.createHmac('sha256',secret).update(Buffer.concat([Buffer.from(t+'\n'+id+'\n'),Buffer.from(rawBody)])).digest('hex');
  if (!equalHex(h['x-payagl-signature'],expected)) throw new Error('Invalid signature');
  return {deliveryId:id,duplicate:!(await reserveDelivery(id))};
}

function headers(secret, apiKey, method, target, rawBody) {
  const timestamp = String(Math.floor(Date.now()/1000)), nonce = crypto.randomBytes(16).toString('hex');
  return { 'content-type':'application/json', 'x-api-key':apiKey, 'X-PayAGL-Timestamp':timestamp,
    'X-PayAGL-Nonce':nonce, 'X-PayAGL-Signature':sign(secret,method,target,timestamp,nonce,rawBody) };
}
module.exports = { headers, sign, canonicalTarget, verifyWebhook };
if (require.main === module) {
  // Synthetic local demonstration only. Transmit this exact Buffer when integrating.
  for (const [target, body] of [['/api/payment/create',{amount:500,order_id:'synthetic-payin'}],['/api/payout/create',{amount:500,order_id:'synthetic-payout',beneficiary_name:'Synthetic',account_number:'12345678',ifsc:'HDFC0000001'}]]) {
    const raw = Buffer.from(JSON.stringify(body));
    console.log(JSON.stringify({target,body:raw.toString(),headers:headers('synthetic-request-secret','synthetic-api-key','POST',target,raw)}));
  }
}
PHP signing and webhook verification
<?php
// No network requests. Use exact serialized bytes for both signing and transmission.
function canonicalTarget(string $target): string {
    if (!str_starts_with($target, '/') || str_starts_with($target, '//') || preg_match('/[\x00-\x20#\\\\]/', $target) || preg_match('/%(?![0-9a-fA-F]{2})/', $target)) throw new Exception('Invalid target');
    $parts = explode('?', $target);
    if (count($parts) > 2 || str_contains($parts[0], '//')) throw new Exception('Invalid target');
    $segments = array_map('rawurldecode', explode('/', $parts[0]));
    foreach ($segments as $s) if ($s === '.' || $s === '..' || preg_match('/[\x00-\x20\/\\\\]/', $s) || !preg_match('//u', $s)) throw new Exception('Invalid path');
    $path = implode('/', array_map('rawurlencode', $segments));
    $pairs = [];
    if (!empty($parts[1])) foreach (explode('&', $parts[1]) as $pair) {
        $p = explode('=', $pair, 2); $k = urldecode($p[0]); $v = urldecode($p[1] ?? '');
        if (!preg_match('//u', $k) || !preg_match('//u', $v)) throw new Exception('Invalid UTF-8');
        $pairs[] = [rawurlencode($k), rawurlencode($v)];
    }
    usort($pairs, fn($a,$b) => strcmp($a[0],$b[0]) ?: strcmp($a[1],$b[1]));
    return $path . (count($pairs) ? '?' . implode('&', array_map(fn($p) => implode('=', $p), $pairs)) : '');
}
function signRequest(string $secret, string $method, string $target, string $timestamp, string $nonce, string $raw): string {
    return hash_hmac('sha256', implode("\n", [strtoupper($method), canonicalTarget($target), $timestamp, $nonce, hash('sha256',$raw)]), $secret);
}
function signedHeaders(string $secret, string $key, string $target, string $raw): array {
    $timestamp = (string)time(); $nonce = bin2hex(random_bytes(16));
    return ['Content-Type'=>'application/json','x-api-key'=>$key,'X-PayAGL-Timestamp'=>$timestamp,'X-PayAGL-Nonce'=>$nonce,'X-PayAGL-Signature'=>signRequest($secret,'POST',$target,$timestamp,$nonce,$raw)];
}
function verifyWebhook(string $secret, string $raw, array $headers, ?int $now = null): string {
    $t = $headers['x-payagl-timestamp'] ?? ''; $id = $headers['x-payagl-delivery-id'] ?? '';
    if (($headers['x-payagl-signature-version'] ?? '') !== 'v1' || !preg_match('/^\d{10}$/D',$t) || !preg_match('/^[A-Za-z0-9_-]{16,128}$/D',$id) || abs(($now ?? time())-(int)$t)>300) throw new Exception('Invalid or expired webhook');
    if (!hash_equals(hash_hmac('sha256',$t."\n".$id."\n".$raw,$secret), $headers['x-payagl-signature'] ?? '')) throw new Exception('Invalid signature');
    return $id; // Atomically deduplicate in durable storage before business processing.
}
if (realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__) {
    foreach ([['/api/payment/create',['amount'=>500,'order_id'=>'synthetic-payin']],['/api/payout/create',['amount'=>500,'order_id'=>'synthetic-payout','beneficiary_name'=>'Synthetic','account_number'=>'12345678','ifsc'=>'HDFC0000001']]] as [$target,$body]) {
        $raw=json_encode($body,JSON_THROW_ON_ERROR|JSON_UNESCAPED_SLASHES); echo json_encode(['target'=>$target,'body'=>$raw,'headers'=>signedHeaders('synthetic-request-secret','synthetic-api-key',$target,$raw)])."\n";
    }
}
Python signing and webhook verification
"""Standard-library signing and webhook verification. No network requests."""
import hashlib
import hmac
import json
import secrets
import time
from urllib.parse import quote, unquote, unquote_plus

def canonical_target(target):
    if not target.startswith('/') or target.startswith('//') or any(ord(c) <= 32 or c in '#\\' for c in target):
        raise ValueError('invalid request target')
    parts = target.split('?')
    if len(parts) > 2 or '//' in parts[0]:
        raise ValueError('invalid request target')
    import re
    if re.search(r'%(?![0-9a-fA-F]{2})', target):
        raise ValueError('invalid percent encoding')
    segments = [unquote(s, errors='strict') for s in parts[0].split('/')]
    if any(s in ('.', '..') or any(ord(c) <= 32 or c in '/\\' for c in s) for s in segments):
        raise ValueError('invalid path')
    path = '/'.join(quote(s, safe='-._~') for s in segments)
    pairs = []
    if len(parts) == 2 and parts[1]:
        for pair in parts[1].split('&'):
            k, _, v = pair.partition('=')
            pairs.append((quote(unquote_plus(k, errors='strict'), safe='-._~'), quote(unquote_plus(v, errors='strict'), safe='-._~')))
    return path + ('?' + '&'.join(k + '=' + v for k, v in sorted(pairs)) if pairs else '')

def sign(secret, method, target, timestamp, nonce, raw_body):
    canonical = '\n'.join([method.upper(), canonical_target(target), str(timestamp), nonce, hashlib.sha256(raw_body).hexdigest()])
    return hmac.new(secret.encode(), canonical.encode(), hashlib.sha256).hexdigest()

def headers(secret, api_key, method, target, raw_body):
    timestamp, nonce = str(int(time.time())), secrets.token_hex(16)
    return {'Content-Type': 'application/json', 'x-api-key': api_key, 'X-PayAGL-Timestamp': timestamp,
            'X-PayAGL-Nonce': nonce, 'X-PayAGL-Signature': sign(secret, method, target, timestamp, nonce, raw_body)}

def verify_webhook(secret, raw_body, headers, now=None):
    """Returns authenticated delivery ID. Atomically deduplicate it in durable storage."""
    import re
    timestamp, delivery = headers.get('x-payagl-timestamp', ''), headers.get('x-payagl-delivery-id', '')
    if headers.get('x-payagl-signature-version') != 'v1' or not re.fullmatch(r'\d{10}', timestamp) or not re.fullmatch(r'[A-Za-z0-9_-]{16,128}', delivery):
        raise ValueError('invalid webhook headers')
    if abs((time.time() if now is None else now) - int(timestamp)) > 300:
        raise ValueError('expired webhook')
    expected = hmac.new(secret.encode(), (timestamp + '\n' + delivery + '\n').encode() + raw_body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, headers.get('x-payagl-signature', '')):
        raise ValueError('invalid webhook signature')
    return delivery

if __name__ == '__main__':
    for target, body in [('/api/payment/create', {'amount':500,'order_id':'synthetic-payin'}),('/api/payout/create',{'amount':500,'order_id':'synthetic-payout','beneficiary_name':'Synthetic','account_number':'12345678','ifsc':'HDFC0000001'})]:
        raw = json.dumps(body, separators=(',', ':')).encode()
        print(json.dumps({'target':target,'body':raw.decode(),'headers':headers('synthetic-request-secret','synthetic-api-key','POST',target,raw)}))