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.
How to create a webhook
You can create and manage your webhooks from the dashboard, on the Webhooks page, or programmatically via the /webhook/ endpoint. When creating a webhook you'll need to specify:
- Description: a name or note to identify the webhook
- URL: your server endpoint that will receive notifications
- Events: the events you want to receive notifications for (see Supported Events)
- Company: the company for which the webhook will be active. If you don't select any, the webhook will be active for all companies in your account
- Enabled: whether the webhook is active or disabled
Once created, the webhook secret will be displayed. Store it securely: it's required to validate signatures and won't be shown again.
Why use webhooks instead of polling
The alternative to webhooks is polling: periodically querying the API to check for updates. This approach has several drawbacks:
- Latency: with polling, you only discover updates at the next query cycle, which can be minutes or hours later. Webhooks notify you in real time, as soon as the event occurs.
- Wasted resources: most polling calls return nothing new, needlessly consuming bandwidth and resources on both the API and your integration.
- Rate limiting: repeated polling calls can cause you to hit rate limits, resulting in errors and delays.
- Complexity: polling requires managing intervals, pagination, and last-query state. Webhooks invert the flow: Invoicetronic calls you when there's something new.
The following diagrams illustrate the difference between the two approaches.
Polling
The client repeatedly queries the API, wasting resources when there are no updates:
sequenceDiagram
participant Client
box Invoicetronic
participant API
end
Client->>API: GET (/receive/) - any updates?
API-->>Client: No updates
Client->>API: GET (/receive/) - any updates?
API-->>Client: No updates
Client->>API: GET (/receive/) - any updates?
API-->>Client: No updates
Note over Client,API: ⏱️ minutes or hours later...
Client->>API: GET (/receive/) - any updates?
API-->>Client: Yes, new invoice!
Note right of Client: Discovered with delay
Webhook
The API notifies the client in real time, only when there's something new:
sequenceDiagram
participant Client
box Invoicetronic
participant API
end
Note over Client,API: Event: new invoice received
API->>Client: POST (webhook endpoint)
Note right of API: Includes Invoicetronic-Signature header
Client->>Client: Validate HMAC-SHA256 signature
Client-->>API: 200 OK
Note right of Client: Real-time notification,<br/>no wasted calls
In short, webhooks are more efficient, more responsive, and simpler to manage than polling.
When polling makes sense
If for technical or infrastructure reasons you can't expose a public endpoint reachable by Invoicetronic, polling remains a valid alternative. In that case, check the rate limiting best practices to optimize your call frequency.
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:
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!
// CORRECT
const payload = req.body.toString('utf8');
if (!validator.validateSignature(signature, payload))
return res.status(401).json({ error: 'Invalid signature' });
const data = JSON.parse(payload);
// WRONG
const data = req.body; // already parsed by express.json()
if (!validator.validateSignature(signature, JSON.stringify(data)))
return res.status(401).json({ error: 'Invalid' }); // stringify might differ!
# CORRECT
payload = request.get_data(as_text=True)
if not validator.validate_signature(signature, payload):
return jsonify({'error': 'Invalid'}), 401
data = json.loads(payload)
# WRONG
data = request.get_json()
if not validator.validate_signature(signature, json.dumps(data)):
return jsonify({'error': 'Invalid'}), 401 # dumps might differ!
// CORRECT
$payload = file_get_contents('php://input');
if (!$validator->validateSignature($signature, $payload)) {
http_response_code(401);
exit;
}
$data = json_decode($payload, true);
// WRONG
$data = json_decode(file_get_contents('php://input'), true);
if (!$validator->validateSignature($signature, json_encode($data))) {
http_response_code(401); // json_encode might differ!
exit;
}
// CORRECT
String payload = requestBody; // raw string
if (!validator.validateSignature(signature, payload))
return ResponseEntity.status(401).build();
Event data = objectMapper.readValue(payload, Event.class);
// WRONG
Event data = objectMapper.readValue(requestBody, Event.class);
if (!validator.validateSignature(signature, objectMapper.writeValueAsString(data)))
return ResponseEntity.status(401).build(); // writeValueAsString might differ!
# CORRECT
payload = request.body.read
unless validator.validate_signature(signature, payload)
return [401, { error: 'Invalid' }.to_json]
end
data = JSON.parse(payload)
# WRONG
data = JSON.parse(request.body.read)
unless validator.validate_signature(signature, data.to_json)
return [401, { error: 'Invalid' }.to_json] # to_json might differ!
end
// CORRECT
bodyBytes, _ := io.ReadAll(r.Body)
payload := string(bodyBytes)
if !validator.ValidateSignature(signature, payload) {
http.Error(w, "Invalid", http.StatusUnauthorized)
return
}
json.Unmarshal(bodyBytes, &data)
// WRONG
json.NewDecoder(r.Body).Decode(&data)
reEncoded, _ := json.Marshal(data)
if !validator.ValidateSignature(signature, string(reEncoded)) { // Marshal might differ!
http.Error(w, "Invalid", http.StatusUnauthorized)
}
// CORRECT
const payload = req.body.toString('utf8');
if (!validator.validateSignature(signature, payload))
return res.status(401).json({ error: 'Invalid signature' });
const data: WebhookEvent = JSON.parse(payload);
// WRONG
const data: WebhookEvent = req.body; // already parsed
if (!validator.validateSignature(signature, JSON.stringify(data)))
return res.status(401).json({ error: 'Invalid' }); // stringify might differ!
5. Automatic Disabling
If you want to disable a webhook via code, respond with HTTP 410 Gone. Invoicetronic will automatically disable it:
Dashboard
You can also disable (or re-enable) a webhook from the dashboard, on the Webhooks page, without modifying your code.
Failure Notifications
If webhook delivery fails 5 consecutive times, Invoicetronic automatically sends an email notification to the address associated with your account. The email includes:
- The URL of the failing webhook
- The last HTTP status code received
- The associated error message
After a notification is sent, another one will only be sent after 24 hours, to avoid spam. The failure counter resets automatically on the first successful delivery.
Delivery history
You can check the delivery history in the dashboard, on the Webhooks page, to diagnose and fix any issues with your endpoint.
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).