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:

HeaderDescription
Unter-SignatureHMAC-SHA256 signature in Stripe-style format
Unter-Event-IdUnique event identifier (e.g. evt_abc123...)
Unter-Event-TypeEvent type (e.g. payment.succeeded)
Content-Typeapplication/json

Event Types

EventDescriptionTrigger
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"
    }
  }
}
FieldTypeDescription
idstringUnique event ID with evt_ prefix
typestringEvent type
createdintUnix timestamp of event creation
data.objectobjectThe 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:

AttemptDelay After Failure
11 minute
23 minutes
35 minutes
410 minutes
530 minutes
62 hours
76 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.

Best Practices
  • Return 200 OK as quickly as possible. Process the event asynchronously if needed.
  • Use the Unter-Event-Id header 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):

MethodPathDescription
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