Webhooks
Webhooks allow you to receive real-time notifications when events occur in your Invoicetronic API integration. Each webhook includes a secret that you must use to validate the authenticity of received calls.
Security
It is essential to always validate webhook signatures to ensure that requests actually come from Invoicetronic and not from malicious sources.
How Signature Works
When Invoicetronic sends a webhook notification to your endpoint, it includes an HTTP header Invoicetronic-Signature with the following structure:
Where:
t: Unix timestamp in seconds (when the request was generated)v1: HMAC-SHA256 signature calculated as follows:- Concatenate the timestamp, a dot, and the JSON payload:
{timestamp}.{jsonPayload} - Calculate HMAC-SHA256 using the webhook secret as the key
- Convert the result to lowercase hexadecimal string
- Concatenate the timestamp, a dot, and the JSON payload:
Client-Side Validation
To validate a received webhook, you must:
- Extract timestamp and signature from the
Invoicetronic-Signatureheader - Verify that the timestamp is not too old (e.g., max 5 minutes)
- Recalculate the signature using the secret and compare it with the received one
- Use a timing-safe comparison to prevent timing attacks
C# / .NET Example
using System.Security.Cryptography;
using System.Text;
public class WebhookValidator
{
private readonly string _secret;
public WebhookValidator(string secret)
{
_secret = secret;
}
public bool ValidateSignature(string signatureHeader, string payload)
{
// Parse the header: "t=1234567890,v1=abcdef..."
var parts = signatureHeader.Split(',');
if (parts.Length != 2) return false;
var timestamp = parts[0].Replace("t=", "");
var receivedSignature = parts[1].Replace("v1=", "");
// Verify timestamp is not too old (max 5 minutes)
var timestampValue = long.Parse(timestamp);
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (Math.Abs(now - timestampValue) > 300)
return false;
// Calculate expected signature
var message = $"{timestamp}.{payload}";
var expectedSignature = ComputeHmacSha256(message, _secret);
// Timing-safe comparison
return TimingSafeEqual(expectedSignature, receivedSignature);
}
private string ComputeHmacSha256(string message, string secret)
{
var encoding = Encoding.UTF8;
var keyBytes = encoding.GetBytes(secret);
var messageBytes = encoding.GetBytes(message);
using var hmac = new HMACSHA256(keyBytes);
var hashBytes = hmac.ComputeHash(messageBytes);
return BitConverter.ToString(hashBytes)
.Replace("-", "")
.ToLower();
}
private bool TimingSafeEqual(string a, string b)
{
if (a.Length != b.Length) return false;
var result = 0;
for (var i = 0; i < a.Length; i++)
result |= a[i] ^ b[i];
return result == 0;
}
}
// Usage in ASP.NET Core endpoint
[HttpPost("webhook")]
public async Task<IActionResult> HandleWebhook(
[FromHeader(Name = "Invoicetronic-Signature")] string signature)
{
// Read body as RAW string
using var reader = new StreamReader(Request.Body, Encoding.UTF8);
var payload = await reader.ReadToEndAsync();
var validator = new WebhookValidator("wh_sec_your_secret_here");
if (!validator.ValidateSignature(signature, payload))
return Unauthorized(new { error = "Invalid signature" });
// Process webhook
var webhookData = JsonSerializer.Deserialize<WebhookEvent>(payload);
// ... process the event ...
return Ok();
}
RAW Body
It's important to read the request body before deserializing it to JSON, to maintain the exact byte representation received.
Node.js / JavaScript Example
const crypto = require('crypto');
const express = require('express');
class WebhookValidator {
constructor(secret) {
this.secret = secret;
}
validateSignature(signatureHeader, payload) {
// Parse the header
const parts = signatureHeader.split(',');
if (parts.length !== 2) return false;
const timestamp = parts[0].replace('t=', '');
const receivedSignature = parts[1].replace('v1=', '');
// Verify timestamp (max 5 minutes)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp)) > 300)
return false;
// Calculate expected signature
const message = `${timestamp}.${payload}`;
const expectedSignature = this.computeHmacSha256(message);
// Timing-safe comparison
return this.timingSafeEqual(expectedSignature, receivedSignature);
}
computeHmacSha256(message) {
return crypto
.createHmac('sha256', this.secret)
.update(message, 'utf8')
.digest('hex');
}
timingSafeEqual(a, b) {
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(
Buffer.from(a, 'utf8'),
Buffer.from(b, 'utf8')
);
}
}
// Usage in Express
const app = express();
const validator = new WebhookValidator('wh_sec_your_secret_here');
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['invoicetronic-signature'];
const payload = req.body.toString('utf8');
if (!validator.validateSignature(signature, payload)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Process webhook
const webhookData = JSON.parse(payload);
// ... process the event ...
res.json({ status: 'ok' });
});
express.raw()
Use express.raw({ type: 'application/json' }) instead of express.json() to maintain the original body needed for validation.
Python Example
import hmac
import hashlib
import time
from flask import Flask, request, jsonify
class WebhookValidator:
def __init__(self, secret: str):
self.secret = secret
def validate_signature(self, signature_header: str, payload: str) -> bool:
# Parse the header
parts = signature_header.split(',')
if len(parts) != 2:
return False
timestamp = parts[0].replace('t=', '')
received_signature = parts[1].replace('v1=', '')
# Verify timestamp (max 5 minutes)
now = int(time.time())
if abs(now - int(timestamp)) > 300:
return False
# Calculate expected signature
message = f"{timestamp}.{payload}"
expected_signature = self.compute_hmac_sha256(message)
# Timing-safe comparison
return hmac.compare_digest(expected_signature, received_signature)
def compute_hmac_sha256(self, message: str) -> str:
return hmac.new(
self.secret.encode('utf-8'),
message.encode('utf-8'),
hashlib.sha256
).hexdigest()
# Usage in Flask
app = Flask(__name__)
validator = WebhookValidator('wh_sec_your_secret_here')
@app.route('/webhook', methods=['POST'])
def handle_webhook():
signature = request.headers.get('Invoicetronic-Signature')
payload = request.get_data(as_text=True)
if not validator.validate_signature(signature, payload):
return jsonify({'error': 'Invalid signature'}), 401
# Process webhook
webhook_data = request.get_json()
# ... process the event ...
return jsonify({'status': 'ok'})
request.get_data()
Use request.get_data(as_text=True) before request.get_json() to get the raw payload needed for validation.
PHP Example
<?php
class WebhookValidator
{
private string $secret;
public function __construct(string $secret)
{
$this->secret = $secret;
}
public function validateSignature(string $signatureHeader, string $payload): bool
{
// Parse the header: "t=1234567890,v1=abcdef..."
$parts = explode(',', $signatureHeader);
if (count($parts) !== 2) {
return false;
}
$timestamp = str_replace('t=', '', $parts[0]);
$receivedSignature = str_replace('v1=', '', $parts[1]);
// Verify timestamp is not too old (max 5 minutes)
$now = time();
if (abs($now - (int)$timestamp) > 300) {
return false;
}
// Calculate expected signature
$message = $timestamp . '.' . $payload;
$expectedSignature = $this->computeHmacSha256($message);
// Timing-safe comparison
return hash_equals($expectedSignature, $receivedSignature);
}
private function computeHmacSha256(string $message): string
{
return hash_hmac('sha256', $message, $this->secret);
}
}
// Usage in standalone PHP script or with frameworks
$validator = new WebhookValidator('wh_sec_your_secret_here');
// Read the header
$signature = $_SERVER['HTTP_INVOICETRONIC_SIGNATURE'] ?? '';
// Read RAW body (important!)
$payload = file_get_contents('php://input');
if (!$validator->validateSignature($signature, $payload)) {
http_response_code(401);
header('Content-Type: application/json');
echo json_encode(['error' => 'Invalid signature']);
exit;
}
// Process webhook
$webhookData = json_decode($payload, true);
// ... process the event ...
http_response_code(200);
header('Content-Type: application/json');
echo json_encode(['status' => 'ok']);
file_get_contents('php://input')
Use file_get_contents('php://input') to read the raw request body. Don't use $_POST as it doesn't work with application/json.
hash_equals()
PHP provides hash_equals() (since PHP 5.6+) which performs a timing-safe comparison to prevent timing attacks.
Security Best Practices
1. Timestamp Validation
Always verify that the timestamp is not too old to prevent replay attacks:
// Example: max 5 minutes difference
var maxAge = TimeSpan.FromMinutes(5);
var requestAge = DateTimeOffset.UtcNow - DateTimeOffset.FromUnixTimeSeconds(timestamp);
if (requestAge > maxAge)
return false;
2. Timing-Safe Comparison
Always use timing-safe comparison functions to prevent timing attacks:
- C#: Implement a custom XOR-based comparison
- Node.js:
crypto.timingSafeEqual() - Python:
hmac.compare_digest() - PHP:
hash_equals()
3. Secret Management
The secret is shown only at webhook creation time. Store it securely:
- Use environment variables
- Use secret managers (Azure Key Vault, AWS Secrets Manager, etc.)
- Encrypt secrets in the database
- Never commit secrets to source code
4. Original Payload
Always validate the payload in its original form before deserializing it:
// CORRECT
var payload = await ReadBodyAsString();
if (!validator.ValidateSignature(signature, payload))
return Unauthorized();
var data = JsonSerializer.Deserialize<Event>(payload);
// WRONG
var data = await Request.ReadFromJsonAsync<Event>();
if (!validator.ValidateSignature(signature, JsonSerializer.Serialize(data)))
return Unauthorized(); // Serialization might differ!
5. Automatic Disabling
If you want to disable a webhook, respond with HTTP 410 Gone. Invoicetronic will automatically disable it:
[HttpPost("webhook")]
public IActionResult HandleWebhook()
{
// If you want to disable the webhook
if (shouldDisableWebhook)
return StatusCode(410); // Gone
// Otherwise process normally
return Ok();
}
Event Structure
Webhook events follow the Event resource structure (see API Reference):
{
"id": 12345,
"user_id": 100,
"company_id": 42,
"resource_id": 789,
"endpoint": "send",
"method": "POST",
"status_code": 201,
"success": true,
"date_time": "2024-01-20T10:30:00Z",
"api_version": 1
}
Supported Events
You can register webhooks for the following events:
send.add- New invoice sentsend.delete- Sent invoice deletedreceive.add- New invoice receivedreceive.delete- Received invoice deletedupdate.add- New status updateupdate.delete- Status update deletedcompany.add- New company createdcompany.delete- Company deleted*- All events
Testing Webhooks
During development, you can use services like:
- webhook.site - Inspect received requests
- ngrok - Expose your local server
- Postman - Test webhook endpoints
Sandbox Environment
Remember that you can test webhooks in the sandbox environment using your test API key (ik_test_...).
Troubleshooting
Webhook not arriving
- Verify that the URL is publicly reachable
- Check that the webhook is enabled (
enabled: true) - Verify that the event is in the list of registered events
- Check webhook history in the dashboard
Invalid signature
- Make sure to read the body before deserializing it
- Verify you're using the correct secret (starts with
wh_sec_) - Check that signature comparison is timing-safe
- Verify that encoding is ASCII for HMAC-SHA256
Webhook gets disabled
Invoicetronic automatically disables webhooks that respond with HTTP 410 Gone. If you don't want a webhook to be disabled, make sure to respond with other status codes even in case of errors (e.g., 200, 500).