Webhook Integration
Receive real-time notifications when payment events occur.
Overview
Unter delivers webhook events as HTTP POST requests to your configured endpoint. Each delivery is signed with HMAC-SHA256, allowing you to verify that the request originated from Unter and has not been tampered with.
Webhook Headers
Every webhook delivery includes these headers:
| Header | Description |
|---|---|
Unter-Signature | HMAC-SHA256 signature in Stripe-style format |
Unter-Event-Id | Unique event identifier (e.g. evt_abc123...) |
Unter-Event-Type | Event type (e.g. payment.succeeded) |
Content-Type | application/json |
Event Types
| Event | Description | Trigger |
|---|---|---|
payment.succeeded |
Payment confirmed on-chain | Transaction verified and payment request fully paid |
payment.failed |
Payment failed | On-chain verification failed after retries |
payment.expired |
Payment request expired | Expiration time reached without full payment |
Payload Format
All webhook payloads use a standard envelope:
{
"id": "evt_abc123def456...",
"type": "payment.succeeded",
"created": 1706000000,
"data": {
"object": {
"id": "pay_...",
"amount": 1000000,
"currency": "USDC",
"status": "succeeded",
"reference": "order_123"
}
}
}
| Field | Type | Description |
|---|---|---|
id | string | Unique event ID with evt_ prefix |
type | string | Event type |
created | int | Unix timestamp of event creation |
data.object | object | The event payload (varies by event type) |
Signature Verification
The Unter-Signature header uses a Stripe-style format:
t=1706000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
The HMAC is computed over the timestamp and raw JSON body, joined by a dot:
HMAC-SHA256(key=webhook_secret, message="<timestamp>.<json_payload>")
Webhook Secret
Your webhook secret is auto-generated when you first configure a webhook URL. It uses the whsec_ prefix followed by 32 alphanumeric characters:
whsec_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
Retrieve your secret via GET /api/webhooks (requires manage_webhooks permission).
Verification Example: PHP
$payload = file_get_contents('php://input');
$signatureHeader = $_SERVER['HTTP_UNTER_SIGNATURE'];
$secret = 'whsec_your_webhook_secret_here';
// Parse the signature header
$parts = explode(',', $signatureHeader);
$timestamp = (int) substr($parts[0], 2); // strip "t="
$signature = substr($parts[1], 3); // strip "v1="
// Reject signatures older than 5 minutes (replay protection)
if (abs(time() - $timestamp) > 300) {
http_response_code(400);
exit('Signature timestamp too old');
}
// Compute expected signature
$expected = hash_hmac('sha256', $timestamp . '.' . $payload, $secret);
// Constant-time comparison
if (!hash_equals($expected, $signature)) {
http_response_code(400);
exit('Invalid signature');
}
// Signature valid — process the event
$event = json_decode($payload, true);
// Handle $event['type'] ...
Verification Example: Node.js
const crypto = require('crypto');
function verifyUnterWebhook(payload, signatureHeader, secret) {
const parts = signatureHeader.split(',');
const timestamp = parseInt(parts[0].substring(2), 10); // strip "t="
const signature = parts[1].substring(3); // strip "v1="
// Reject signatures older than 5 minutes
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > 300) {
throw new Error('Signature timestamp too old');
}
// Compute expected signature
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${payload}`)
.digest('hex');
// Constant-time comparison
if (!crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(signature, 'hex')
)) {
throw new Error('Invalid signature');
}
return JSON.parse(payload);
}
// Express.js example
app.post('/webhooks/unter', express.raw({ type: 'application/json' }), (req, res) => {
try {
const event = verifyUnterWebhook(
req.body.toString(),
req.headers['unter-signature'],
process.env.UNTER_WEBHOOK_SECRET
);
console.log('Received event:', event.type);
res.sendStatus(200);
} catch (err) {
console.error('Webhook verification failed:', err.message);
res.sendStatus(400);
}
});
Verification Example: Python
import hmac
import hashlib
import time
import json
def verify_unter_webhook(payload: bytes, signature_header: str, secret: str) -> dict:
parts = signature_header.split(',')
timestamp = int(parts[0][2:]) # strip "t="
signature = parts[1][3:] # strip "v1="
# Reject signatures older than 5 minutes
if abs(time.time() - timestamp) > 300:
raise ValueError('Signature timestamp too old')
# Compute expected signature
message = f"{timestamp}.{payload.decode('utf-8')}"
expected = hmac.new(
secret.encode('utf-8'),
message.encode('utf-8'),
hashlib.sha256
).hexdigest()
# Constant-time comparison
if not hmac.compare_digest(expected, signature):
raise ValueError('Invalid signature')
return json.loads(payload)
# Flask example
from flask import Flask, request
app = Flask(__name__)
@app.route('/webhooks/unter', methods=['POST'])
def handle_webhook():
try:
event = verify_unter_webhook(
request.data,
request.headers.get('Unter-Signature', ''),
'whsec_your_webhook_secret_here'
)
print(f"Received event: {event['type']}")
return '', 200
except ValueError as e:
print(f"Webhook verification failed: {e}")
return '', 400
Retry Behavior
Failed webhook deliveries are retried up to 7 times with increasing backoff delays:
| Attempt | Delay After Failure |
|---|---|
| 1 | 1 minute |
| 2 | 3 minutes |
| 3 | 5 minutes |
| 4 | 10 minutes |
| 5 | 30 minutes |
| 6 | 2 hours |
| 7 | 6 hours |
A delivery is considered successful when your endpoint responds with a 2xx status code within 10 seconds. Any other response (or timeout) triggers a retry.
- Return
200 OKas quickly as possible. Process the event asynchronously if needed. - Use the
Unter-Event-Idheader to deduplicate events (the same event may be delivered multiple times on retry). - Always verify the signature before processing the payload.
- Reject signatures older than 5 minutes to prevent replay attacks.
Managing Webhooks
Use the webhook management endpoints (requires manage_webhooks permission):
| Method | Path | Description |
|---|---|---|
| GET | /api/webhooks |
View current webhook URL and secret |
| PUT | /api/webhooks |
Update webhook URL and settings |
| POST | /api/webhooks/test |
Send a test webhook to your endpoint |