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
Validatione examples
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.
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.
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
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.
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
public class WebhookValidator {
private final String secret;
public WebhookValidator(String secret) {
this.secret = secret;
}
public boolean validateSignature(String signatureHeader, String payload) {
try {
// Parse the header: "t=1234567890,v1=abcdef..."
String[] parts = signatureHeader.split(",");
if (parts.length != 2) {
return false;
}
String timestamp = parts[0].replace("t=", "");
String receivedSignature = parts[1].replace("v1=", "");
// Verify timestamp is not too old (max 5 minutes)
long timestampValue = Long.parseLong(timestamp);
long now = Instant.now().getEpochSecond();
if (Math.abs(now - timestampValue) > 300) {
return false;
}
// Calculate expected signature
String message = timestamp + "." + payload;
String expectedSignature = computeHmacSha256(message);
// Timing-safe comparison
return timingSafeEqual(expectedSignature, receivedSignature);
} catch (Exception e) {
return false;
}
}
private String computeHmacSha256(String message)
throws NoSuchAlgorithmException, InvalidKeyException {
Mac hmac = Mac.getInstance("HmacSHA256");
SecretKeySpec secretKey = new SecretKeySpec(
secret.getBytes(StandardCharsets.UTF_8),
"HmacSHA256"
);
hmac.init(secretKey);
byte[] hashBytes = hmac.doFinal(message.getBytes(StandardCharsets.UTF_8));
// Convert to lowercase hexadecimal
StringBuilder hexString = new StringBuilder();
for (byte b : hashBytes) {
String hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) hexString.append('0');
hexString.append(hex);
}
return hexString.toString();
}
private boolean timingSafeEqual(String a, String b) {
if (a.length() != b.length()) {
return false;
}
int result = 0;
for (int i = 0; i < a.length(); i++) {
result |= a.charAt(i) ^ b.charAt(i);
}
return result == 0;
}
}
// Usage in Spring Boot
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.util.Map;
@RestController
public class WebhookController {
private final WebhookValidator validator =
new WebhookValidator("wh_sec_your_secret_here");
@PostMapping("/webhook")
public ResponseEntity<?> handleWebhook(
@RequestHeader("Invoicetronic-Signature") String signature,
@RequestBody String payload) {
if (!validator.validateSignature(signature, payload)) {
return ResponseEntity
.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Invalid signature"));
}
// Process webhook
// WebhookEvent event = objectMapper.readValue(payload, WebhookEvent.class);
// ... process the event ...
return ResponseEntity.ok(Map.of("status", "ok"));
}
}
@RequestBody String
Use @RequestBody String instead of deserializing directly to an object to maintain the raw payload needed for validation.
require 'openssl'
require 'json'
class WebhookValidator
def initialize(secret)
@secret = secret
end
def validate_signature(signature_header, payload)
# Parse the header
parts = signature_header.split(',')
return false if parts.length != 2
timestamp = parts[0].sub('t=', '')
received_signature = parts[1].sub('v1=', '')
# Verify timestamp (max 5 minutes)
now = Time.now.to_i
return false if (now - timestamp.to_i).abs > 300
# Calculate expected signature
message = "#{timestamp}.#{payload}"
expected_signature = compute_hmac_sha256(message)
# Timing-safe comparison
timing_safe_equal(expected_signature, received_signature)
end
private
def compute_hmac_sha256(message)
OpenSSL::HMAC.hexdigest('sha256', @secret, message)
end
def timing_safe_equal(a, b)
return false if a.length != b.length
# Ruby 2.5.1+ has Rack::Utils.secure_compare
# For earlier versions, we implement XOR comparison
result = 0
a.bytes.zip(b.bytes) { |x, y| result |= x ^ y }
result.zero?
end
end
# Usage in Sinatra
require 'sinatra'
validator = WebhookValidator.new('wh_sec_your_secret_here')
post '/webhook' do
signature = request.env['HTTP_INVOICETRONIC_SIGNATURE']
payload = request.body.read
unless validator.validate_signature(signature, payload)
status 401
return { error: 'Invalid signature' }.to_json
end
# Process webhook
webhook_data = JSON.parse(payload)
# ... process the event ...
content_type :json
{ status: 'ok' }.to_json
end
# Usage in Rails
# class WebhooksController < ApplicationController
# skip_before_action :verify_authenticity_token
#
# def create
# validator = WebhookValidator.new('wh_sec_your_secret_here')
# signature = request.headers['Invoicetronic-Signature']
# payload = request.raw_post
#
# unless validator.validate_signature(signature, payload)
# render json: { error: 'Invalid signature' }, status: :unauthorized
# return
# end
#
# webhook_data = JSON.parse(payload)
# # ... process the event ...
#
# render json: { status: 'ok' }
# end
# end
request.body.read / request.raw_post
In Sinatra use request.body.read, in Rails use request.raw_post to get the raw payload before JSON parsing.
package main
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"io"
"math"
"net/http"
"strconv"
"strings"
"time"
)
type WebhookValidator struct {
secret string
}
func NewWebhookValidator(secret string) *WebhookValidator {
return &WebhookValidator{secret: secret}
}
func (v *WebhookValidator) ValidateSignature(signatureHeader, payload string) bool {
// Parse the header: "t=1234567890,v1=abcdef..."
parts := strings.Split(signatureHeader, ",")
if len(parts) != 2 {
return false
}
timestamp := strings.TrimPrefix(parts[0], "t=")
receivedSignature := strings.TrimPrefix(parts[1], "v1=")
// Verify timestamp is not too old (max 5 minutes)
timestampValue, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return false
}
now := time.Now().Unix()
if math.Abs(float64(now-timestampValue)) > 300 {
return false
}
// Calculate expected signature
message := timestamp + "." + payload
expectedSignature := v.computeHmacSha256(message)
// Timing-safe comparison
return subtle.ConstantTimeCompare(
[]byte(expectedSignature),
[]byte(receivedSignature),
) == 1
}
func (v *WebhookValidator) computeHmacSha256(message string) string {
h := hmac.New(sha256.New, []byte(v.secret))
h.Write([]byte(message))
return hex.EncodeToString(h.Sum(nil))
}
// Usage in HTTP handler
func main() {
validator := NewWebhookValidator("wh_sec_your_secret_here")
http.HandleFunc("/webhook", func(w http.ResponseWriter, r *http.Request) {
// Read the header
signature := r.Header.Get("Invoicetronic-Signature")
// Read RAW body
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Error reading body", http.StatusBadRequest)
return
}
payload := string(bodyBytes)
// Validate signature
if !validator.ValidateSignature(signature, payload) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]string{
"error": "Invalid signature",
})
return
}
// Process webhook
var webhookData map[string]interface{}
if err := json.Unmarshal(bodyBytes, &webhookData); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
// ... process the event ...
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
})
})
http.ListenAndServe(":8080", nil)
}
io.ReadAll(r.Body)
Read the body with io.ReadAll(r.Body) before deserializing, so you can use the raw bytes for validation.
subtle.ConstantTimeCompare()
Go provides crypto/subtle.ConstantTimeCompare() for native timing-safe comparisons.
import crypto from 'crypto';
import express, { Request, Response } from 'express';
class WebhookValidator {
private secret: string;
constructor(secret: string) {
this.secret = secret;
}
validateSignature(signatureHeader: string, payload: string): boolean {
// 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);
}
private computeHmacSha256(message: string): string {
return crypto
.createHmac('sha256', this.secret)
.update(message, 'utf8')
.digest('hex');
}
private timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(
Buffer.from(a, 'utf8'),
Buffer.from(b, 'utf8')
);
}
}
// Usage in Express with TypeScript
const app = express();
const validator = new WebhookValidator('wh_sec_your_secret_here');
interface WebhookEvent {
id: number;
user_id: number;
company_id: number;
resource_id: number;
endpoint: string;
method: string;
status_code: number;
success: boolean;
date_time: string;
api_version: number;
}
app.post(
'/webhook',
express.raw({ type: 'application/json' }),
(req: Request, res: Response) => {
const signature = req.headers['invoicetronic-signature'] as string;
const payload = req.body.toString('utf8');
if (!validator.validateSignature(signature, payload)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Process webhook
const webhookData: WebhookEvent = JSON.parse(payload);
// ... process the event ...
res.json({ status: 'ok' });
}
);
app.listen(3000, () => {
console.log('Webhook server listening on port 3000');
});
Type safety
TypeScript allows you to define interfaces for webhook data, improving type safety and IDE autocomplete.
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).