Webhooks¶
When a buyer completes payment on an ExtendPay session and it reaches Complete, Billtrust can POST a signed JSON notification to your webhook URL.
Webhooks are optional. Without both a URL and a signing secret, Billtrust does not call your system.
Configuration¶
Successful webhook delivery requires both:
| Setting | Description |
|---|---|
| Webhook URL | Absolute https URL that accepts Billtrust POSTs |
| Signing secret | Secret starting with whsec_…, used to sign ExtendPay-Signature |
These values are not set through the ExtendPay session API. A supplier administrator configures them in Billtrust payments configuration (or an internal Billtrust resource does so on your behalf). After the secret is generated, store the value securely and use it when verifying signatures. Regenerating the secret replaces it immediately—update your verifier at the same time.
Delivery¶
POST {your-webhookUrl}
Content-Type: application/json; charset=utf-8
ExtendPay-Signature: t=1710000000,v1=abc123...
Billtrust:
- Sends
POSTonly - Does not follow redirects
- Waits up to 10 seconds
- Treats any 2xx as success
- Retries on non-2xx, timeouts, and connection errors
Payload¶
{
"apiVersion": "v1",
"type": "extendpay.session.completed",
"createdAt": "2026-08-11T15:12:04Z",
"data": {
"extendPaySessionId": "c0ffee00-0000-4000-8000-000000000001",
"status": "Complete",
"sourceType": "supplierPortal",
"accountNumber": "ACCT-100",
"user": {
"email": "buyer@example.com",
"firstName": "Ada",
"lastName": "Lovelace"
},
"onCompleteUrl": "https://supplier.example/return",
"createdAt": "2026-08-11T15:00:00Z",
"updatedAt": "2026-08-11T15:12:04Z",
"paymentReference": "pay-ref-1",
"transactions": [
{
"status": "Complete",
"totalAmount": 175.0,
"currencyCode": "USD",
"transactionReference": "txn-1",
"paymentDate": "2026-08-11T15:11:30Z",
"scheduled": false,
"paymentAccountDisplayName": "Visa ••4242",
"invoices": {
"items": [
{
"invoiceNumber": "INV-100",
"totalDue": 100.0,
"dueDate": "2026-09-15",
"billDate": "2026-08-01",
"amountApplied": 100.0,
"appliedDiscount": 0.0,
"isShortPay": false,
"isOverPay": false
}
]
},
"statements": {
"items": [
{
"totalDue": 75.0,
"dueDate": "2026-09-20",
"billDate": "2026-08-05",
"amountApplied": 75.0,
"appliedDiscount": 5.0,
"isShortPay": true,
"isOverPay": false
}
]
}
}
]
}
}
| Field | Description |
|---|---|
apiVersion |
v1 |
type |
extendpay.session.completed |
createdAt |
When the notification was created (UTC) |
data |
Session body — same fields as Get session |
Verify the signature against the raw body exactly as received. Do not re-serialize JSON before verifying.
Verify the signature¶
ExtendPay-Signature: t=<unixSeconds>,v1=<hexDigest>
| Part | Meaning |
|---|---|
t |
Unix timestamp (seconds) |
v1 |
Lowercase hex HMAC-SHA256 digest |
Steps
- Read the raw request body.
- Parse
tandv1fromExtendPay-Signature. - Reject if
|now - t|is greater than 300 seconds (5 minutes). - Build
{t}.{rawBody}. - Compute HMAC-SHA256 with your full signing secret (including
whsec_). - Hex-encode in lowercase and compare to
v1with a constant-time check. - Only then parse and process the JSON.
Example (Node.js)¶
const crypto = require('crypto');
function verifyExtendPaySignature(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(
signatureHeader.split(',').map((p) => {
const [k, v] = p.trim().split('=');
return [k, v];
})
);
const timestamp = parts.t;
const expected = parts.v1;
if (!timestamp || !expected) {
return false;
}
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (Number.isNaN(age) || age > toleranceSeconds) {
return false;
}
const signedPayload = `${timestamp}.${rawBody}`;
const digest = crypto
.createHmac('sha256', secret)
.update(signedPayload, 'utf8')
.digest('hex');
const a = Buffer.from(digest, 'utf8');
const b = Buffer.from(expected, 'utf8');
if (a.length !== b.length) {
return false;
}
return crypto.timingSafeEqual(a, b);
}
Example (C#)¶
using System.Security.Cryptography;
using System.Text;
static bool VerifyExtendPaySignature(
string rawBody,
string signatureHeader,
string secret,
int toleranceSeconds = 300)
{
long? t = null;
string v1 = null;
foreach (var part in signatureHeader.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var idx = part.IndexOf('=');
if (idx <= 0) continue;
var key = part[..idx];
var value = part[(idx + 1)..];
if (key == "t" && long.TryParse(value, out var ts)) t = ts;
if (key == "v1") v1 = value;
}
if (t is null || string.IsNullOrEmpty(v1)) return false;
var age = Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - t.Value);
if (age > toleranceSeconds) return false;
var signedPayload = $"{t.Value}.{rawBody}";
var keyBytes = Encoding.UTF8.GetBytes(secret);
var payloadBytes = Encoding.UTF8.GetBytes(signedPayload);
var hash = HMACSHA256.HashData(keyBytes, payloadBytes);
var digest = Convert.ToHexString(hash).ToLowerInvariant();
var a = Encoding.UTF8.GetBytes(digest);
var b = Encoding.UTF8.GetBytes(v1);
return a.Length == b.Length && CryptographicOperations.FixedTimeEquals(a, b);
}
Your response¶
| Your response | Billtrust behavior |
|---|---|
| 2xx | Success |
| Non-2xx, timeout, or connection error | Retry |
| Slow handler (> 10s) | Treated as failure; may retry |
Verify the signature, acknowledge quickly with 2xx, and process heavy work asynchronously.
Retries¶
Failed deliveries retry about every two minutes, up to 10 attempts. After that, fetch the session with Get session. There is no replay API.
Security checklist¶
- Use HTTPS only
- Verify
ExtendPay-Signatureon every request - Enforce the 5-minute timestamp window
- Store the signing secret securely
- Update your verifier immediately after rotating the secret
- Deduplicate on
data.extendPaySessionId