Webhooks
Real-time notifications for invoice payment events
Overview
When creating an invoice, you can provide a webhook_url in the request body. This URL will receive a POST request when payment is confirmed and all fees are calculated.
How Webhooks Work
Provide Webhook URL
Include webhook_url in the request body when creating an invoice via POST /api/v2/invoice
Invoice Status Changes
A customer makes a payment or an invoice status changes.
SafePays Sends Notification
SafePays sends a POST request with the invoice data to your webhook URL.
Process the Event
Your server processes the webhook payload and updates your system accordingly.
Webhook Payload
When an invoice status changes, your webhook URL receives a POST request containing the full invoice data. Check the status field to determine if payment was successful.
Example Payload
{
"invoice": {
"id": "660e8400-e29b-41d4-a716-446655440001",
"amount": 125.00,
"currency": "USD",
"email": "john.doe@example.com",
"provider": "card",
"status": "Paid",
"created_on": "15 Jan, 2024",
"paid_on": "15 Jan, 2024",
"payment_link": "https://app.safepays.com/pay/660e8400-e29b-41d4-a716-446655440001",
"items": [
{
"name": "Product A",
"qty": 2,
"price": 50.00
}
],
"due_date": "2024-12-31",
"customer_id": "550e8400-e29b-41d4-a716-446655440000"
}
}Invoice Status Values
| Status | Description |
|---|---|
Invoice Created | Invoice created, awaiting payment |
Paid | Payment received successfully |
Unpaid | Invoice is past due |
Failed | Payment attempt failed |
Implementing a Webhook Endpoint
Your webhook endpoint should:
- Accept POST requests with a JSON body
- Process the invoice data based on the
statusfield - Return a
200 OKresponse quickly
Example Implementations
app.post('/webhook/safepays', express.json(), async (req, res) => {
try {
const { invoice } = req.body;
switch (invoice.status) {
case 'Paid':
await handlePaymentSuccess(invoice);
break;
case 'Failed':
await handlePaymentFailed(invoice);
break;
}
res.status(200).json({ received: true });
} catch (error) {
console.error('Webhook error:', error);
res.status(500).send('Webhook processing failed');
}
});
async function handlePaymentSuccess(invoice) {
await db.invoices.update({
where: { external_id: invoice.id },
data: {
status: 'paid',
paid_at: invoice.paid_on
}
});
await sendPaymentConfirmation(invoice.email, invoice);
}
async function handlePaymentFailed(invoice) {
await db.payment_failures.create({
invoice_id: invoice.id,
failed_at: new Date()
});
await sendPaymentFailedEmail(invoice.email, invoice);
}from flask import Flask, request, jsonify
app = Flask(__name__)
@app.route('/webhook/safepays', methods=['POST'])
def safepays_webhook():
try:
data = request.get_json()
invoice = data.get('invoice')
if invoice['status'] == 'Paid':
handle_payment_success(invoice)
elif invoice['status'] == 'Failed':
handle_payment_failed(invoice)
return jsonify({'received': True}), 200
except Exception as e:
print(f'Webhook error: {e}')
return jsonify({'error': 'Webhook processing failed'}), 500
def handle_payment_success(invoice):
db.execute(
"UPDATE invoices SET status = 'paid', paid_at = %s WHERE external_id = %s",
(invoice['paid_on'], invoice['id'])
)
send_payment_confirmation(invoice['email'], invoice)
def handle_payment_failed(invoice):
db.execute(
"INSERT INTO payment_failures (invoice_id, failed_at) VALUES (%s, NOW())",
(invoice['id'],)
)
send_payment_failed_email(invoice['email'], invoice)<?php
$payload = file_get_contents('php://input');
$data = json_decode($payload, true);
$invoice = $data['invoice'] ?? [];
try {
switch ($invoice['status'] ?? '') {
case 'Paid':
handlePaymentSuccess($invoice);
break;
case 'Failed':
handlePaymentFailed($invoice);
break;
}
http_response_code(200);
echo json_encode(['received' => true]);
} catch (Exception $e) {
error_log('Webhook error: ' . $e->getMessage());
http_response_code(500);
echo json_encode(['error' => 'Webhook processing failed']);
}
function handlePaymentSuccess($invoice) {
global $db;
$stmt = $db->prepare(
"UPDATE invoices SET status = 'paid', paid_at = ? WHERE external_id = ?"
);
$stmt->execute([$invoice['paid_on'], $invoice['id']]);
sendPaymentConfirmation($invoice['email'], $invoice);
}
function handlePaymentFailed($invoice) {
global $db;
$stmt = $db->prepare(
"INSERT INTO payment_failures (invoice_id, failed_at) VALUES (?, NOW())"
);
$stmt->execute([$invoice['id']]);
sendPaymentFailedEmail($invoice['email'], $invoice);
}
?>Webhook URL Requirements
- Must be a valid HTTP or HTTPS URL
- Must accept POST requests with
Content-Type: application/json - Should return a
200–299status code to acknowledge receipt - Should respond within 10 seconds
Webhook URLs are provided at invoice creation time via the webhook_url field in the request body. There is no separate webhook registration endpoint.
Testing Webhooks
Local Development
For local testing, use a tunnel service to expose your local server:
# Using ngrok
ngrok http 3000
# Use the HTTPS URL as your webhook parameter
# Example: https://abc123.ngrok.io/webhook/safepaysAlternatively, use Webhook.site to inspect incoming webhook payloads without writing any code.
Test Mode
When using test API keys:
- Create invoices normally
- Mark them as paid through the SafePays dashboard
- Your webhook URL will receive the notification with test payment data
Best Practices
- Respond quickly — Return
200 OKimmediately and process the webhook asynchronously if needed - Handle duplicates — Webhooks may be delivered more than once; use the invoice
idto deduplicate - Use HTTPS — Always use HTTPS endpoints for webhook URLs
- Verify the data — Use the Check Invoice Status endpoint to confirm payment details if needed
- Log everything — Log all webhook payloads for debugging and reconciliation