Receive real-time notifications when events occur in your Uptillo account.
Webhooks allow your application to receive automatic notifications when certain events happen, such as:
- Client responds to an invoice reminder
- Email is delivered or bounces
- Invoice chase status changes
- Payment promised by client
- Log in to your Uptillo dashboard
- Navigate to Settings > Webhooks
- Click Add Webhook Endpoint
- Enter your endpoint URL (must be HTTPS)
- Select events to subscribe to
- Save and copy your webhook signing secret
- Must use HTTPS (except localhost for testing)
- Respond within 5 seconds with HTTP 200
- Handle duplicate events (webhooks may be sent more than once)
- Verify webhook signatures for security
Triggered when a client responds to an invoice reminder via the smart link.
{
"event": "invoice.response.submitted",
"timestamp": "2024-02-10T15:30:00.000Z",
"data": {
"id": "resp_abc123",
"invoice_chase_id": "chase_def456",
"account_id": "acc_xyz789",
"response_type": "WILL_PAY",
"scheduled_date": "2024-02-15T00:00:00.000Z",
"message": "Will pay by end of week",
"submitted_at": "2024-02-10T15:30:00.000Z",
"invoice": {
"invoice_number": "INV-2024-001",
"invoice_amount": "1250.00",
"currency": "EUR",
"due_date": "2024-02-05T00:00:00.000Z"
},
"client": {
"id": "client_ghi789",
"client_name": "ACME Corp",
"client_email": "billing@acme.com"
}
}
}Response Types:
WILL_PAY- Client promises to pay on a specific dateALREADY_PAID- Client claims they already paidDISPUTE- Client disputes the invoiceNEED_MORE_TIME- Client needs more time to pay
Triggered when an email is successfully delivered.
{
"event": "email.delivered",
"timestamp": "2024-02-10T09:05:00.000Z",
"data": {
"id": "log_abc123",
"invoice_chase_id": "chase_def456",
"recipient_email": "billing@acme.com",
"subject": "Friendly reminder: Invoice INV-2024-001 is overdue",
"sent_at": "2024-02-10T09:00:00.000Z",
"delivered_at": "2024-02-10T09:05:00.000Z",
"template_key": "followup_3_days"
}
}Triggered when a recipient opens an email.
{
"event": "email.opened",
"timestamp": "2024-02-10T10:15:00.000Z",
"data": {
"id": "log_abc123",
"invoice_chase_id": "chase_def456",
"recipient_email": "billing@acme.com",
"opened_at": "2024-02-10T10:15:00.000Z",
"user_agent": "Mozilla/5.0..."
}
}Triggered when a recipient clicks a link in the email.
{
"event": "email.clicked",
"timestamp": "2024-02-10T10:16:00.000Z",
"data": {
"id": "log_abc123",
"invoice_chase_id": "chase_def456",
"recipient_email": "billing@acme.com",
"clicked_at": "2024-02-10T10:16:00.000Z",
"link_url": "https://uptillo.com/respond?token=..."
}
}Triggered when an email bounces.
{
"event": "email.bounced",
"timestamp": "2024-02-10T09:10:00.000Z",
"data": {
"id": "log_abc123",
"invoice_chase_id": "chase_def456",
"recipient_email": "invalid@example.com",
"bounce_type": "hard",
"error_message": "Mailbox does not exist",
"bounced_at": "2024-02-10T09:10:00.000Z"
}
}Bounce Types:
hard- Permanent failure (invalid email address)soft- Temporary failure (mailbox full, server down)
Triggered when a new invoice chase is created.
{
"event": "invoice_chase.created",
"timestamp": "2024-02-10T14:00:00.000Z",
"data": {
"id": "chase_abc123",
"invoice_number": "INV-2024-001",
"invoice_amount": "1250.00",
"currency": "EUR",
"due_date": "2024-02-05T00:00:00.000Z",
"status": "ACTIVE",
"client": {
"id": "client_def456",
"client_name": "ACME Corp",
"client_email": "billing@acme.com"
}
}
}Triggered when an invoice chase status changes.
{
"event": "invoice_chase.status_changed",
"timestamp": "2024-02-10T16:00:00.000Z",
"data": {
"id": "chase_abc123",
"invoice_number": "INV-2024-001",
"old_status": "ACTIVE",
"new_status": "PAID",
"changed_at": "2024-02-10T16:00:00.000Z"
}
}Triggered when a client unsubscribes from reminders.
{
"event": "client.opted_out",
"timestamp": "2024-02-10T11:00:00.000Z",
"data": {
"client_email": "billing@acme.com",
"opted_out_at": "2024-02-10T11:00:00.000Z",
"reason": "Already paid all invoices",
"affected_chases": [
{
"id": "chase_abc123",
"invoice_number": "INV-2024-001"
}
]
}
}All webhook requests include a signature in the X-Uptillo-Signature header. Verify this signature to ensure the webhook came from Uptillo.
X-Uptillo-Signature: t=1707571200,v1=5f9b2c1e8d7a6b4c3e2f1a0b9c8d7e6f...
- Extract timestamp (
t) and signature (v1) from header - Construct signed payload:
{timestamp}.{raw_body} - Compute HMAC SHA256 using your webhook secret
- Compare computed signature with provided signature
const crypto = require('crypto');
function verifyWebhookSignature(payload, signature, secret) {
const parts = signature.split(',');
const timestamp = parts.find(p => p.startsWith('t=')).split('=')[1];
const receivedSig = parts.find(p => p.startsWith('v1=')).split('=')[1];
// Check timestamp (prevent replay attacks)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp)) > 300) { // 5 minutes
throw new Error('Webhook timestamp too old');
}
// Compute signature
const signedPayload = `${timestamp}.${payload}`;
const expectedSig = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
// Compare signatures
if (receivedSig !== expectedSig) {
throw new Error('Invalid webhook signature');
}
return true;
}
// Express.js example
app.post('/webhooks/uptillo', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-uptillo-signature'];
const webhookSecret = process.env.UPTILLO_WEBHOOK_SECRET;
try {
verifyWebhookSignature(req.body.toString(), signature, webhookSecret);
const event = JSON.parse(req.body);
// Handle event
switch (event.event) {
case 'invoice.response.submitted':
handleClientResponse(event.data);
break;
case 'email.delivered':
handleEmailDelivered(event.data);
break;
// ... other events
}
res.status(200).json({ received: true });
} catch (error) {
console.error('Webhook verification failed:', error.message);
res.status(400).json({ error: 'Invalid signature' });
}
});import hmac
import hashlib
import time
def verify_webhook_signature(payload, signature, secret):
parts = dict(part.split('=') for part in signature.split(','))
timestamp = parts['t']
received_sig = parts['v1']
# Check timestamp (prevent replay attacks)
now = int(time.time())
if abs(now - int(timestamp)) > 300: # 5 minutes
raise ValueError('Webhook timestamp too old')
# Compute signature
signed_payload = f"{timestamp}.{payload}"
expected_sig = hmac.new(
secret.encode(),
signed_payload.encode(),
hashlib.sha256
).hexdigest()
# Compare signatures
if not hmac.compare_digest(received_sig, expected_sig):
raise ValueError('Invalid webhook signature')
return True
# Flask example
@app.route('/webhooks/uptillo', methods=['POST'])
def webhook():
signature = request.headers.get('X-Uptillo-Signature')
webhook_secret = os.getenv('UPTILLO_WEBHOOK_SECRET')
try:
verify_webhook_signature(request.data.decode(), signature, webhook_secret)
event = request.json
# Handle event
if event['event'] == 'invoice.response.submitted':
handle_client_response(event['data'])
elif event['event'] == 'email.delivered':
handle_email_delivered(event['data'])
# ... other events
return {'received': True}, 200
except ValueError as e:
print(f'Webhook verification failed: {e}')
return {'error': 'Invalid signature'}, 400✅ DO:
- Respond quickly with HTTP 200 (within 5 seconds)
- Process webhooks asynchronously (use a queue)
- Store raw webhook data for debugging
- Handle duplicate events (use event ID for idempotency)
- Verify webhook signatures
- Log all webhook events
- Implement retry logic for failed processing
❌ DON'T:
- Perform long-running tasks synchronously
- Return errors for successfully received webhooks
- Trust webhooks without signature verification
- Assume events arrive in order
- Ignore duplicate events
// Express.js with job queue
app.post('/webhooks/uptillo', express.json(), async (req, res) => {
const signature = req.headers['x-uptillo-signature'];
try {
// Verify signature
verifyWebhookSignature(JSON.stringify(req.body), signature, process.env.UPTILLO_WEBHOOK_SECRET);
// Respond immediately
res.status(200).json({ received: true });
// Process asynchronously
await jobQueue.add('processWebhook', {
event: req.body,
receivedAt: new Date()
});
} catch (error) {
res.status(400).json({ error: error.message });
}
});If your endpoint returns an error or times out, Uptillo will retry the webhook:
- Retry Schedule: 1 hour, 6 hours, 24 hours
- Max Retries: 3 attempts
- Timeout: 5 seconds per attempt
After 3 failed attempts, the webhook is marked as failed and you'll receive an email notification.
Use a tool like ngrok to expose your local server:
ngrok http 3000Then use the ngrok URL as your webhook endpoint:
https://abc123.ngrok.io/webhooks/uptillo
Send a test webhook from the dashboard:
- Go to Settings > Webhooks
- Click on your webhook endpoint
- Click Send Test Event
- Select event type
- View the response
curl -X POST https://your-app.com/webhooks/uptillo \
-H "Content-Type: application/json" \
-H "X-Uptillo-Signature: t=1707571200,v1=..." \
-d '{
"event": "invoice.response.submitted",
"timestamp": "2024-02-10T15:30:00.000Z",
"data": {
"id": "resp_test123",
"invoice_chase_id": "chase_test456",
"response_type": "WILL_PAY",
"scheduled_date": "2024-02-15T00:00:00.000Z",
"message": "Test response"
}
}'View webhook delivery logs in your dashboard:
- Go to Settings > Webhooks
- Click on your webhook endpoint
- View Recent Deliveries
Each log entry shows:
- Event type
- Timestamp
- HTTP response code
- Response time
- Response body
- Retry attempts
If signature verification fails, return:
{
"error": "Invalid signature"
}HTTP Status: 400 Bad Request
If webhook processing fails, return:
{
"error": "Failed to process webhook",
"details": "Database connection error"
}HTTP Status: 500 Internal Server Error
Uptillo will retry the webhook automatically.
- Always verify signatures - Never trust webhooks without verification
- Use HTTPS - Webhooks are only sent to HTTPS endpoints (except localhost)
- Check timestamps - Reject webhooks with old timestamps (> 5 minutes)
- Rate limiting - Implement rate limiting on webhook endpoints
- Idempotency - Use event IDs to prevent duplicate processing
- Secret rotation - Rotate webhook secrets periodically