This guide explains how to receive webhooks from SUPER. It is written for developers who build the receiving endpoint on the client side.
A webhook is an HTTP POST that SUPER sends to your URL when something happens. You do not have to poll our API. When an event happens, we call your endpoint.
You create an endpoint in the SUPER web app. You give us:
https and publicly reachable),whsec_. Copy it and store it safely. You cannot see it again. If you lose it, rotate the secret in the app to get a new one.
invoice.received eventRight now one event is available:
invoice.received - a new inbound invoice is available for your company.The webhook does not contain the full invoice. It tells you an invoice arrived and gives you its invoiceGuid. You then call GetInvoice in our API with that invoiceGuid to read the full invoice.
Every webhook POST has these headers:
| Header | Meaning |
|---|---|
X-Super-Webhook-Id | Unique id of this delivery. Same value as id in the body. Use it to detect duplicates. |
X-Super-Webhook-Event | The event type, for example invoice.received. |
X-Super-Webhook-Timestamp | Time we created the request, as a Unix timestamp in seconds (UTC). |
X-Super-Webhook-Signature | The signature of the request. Use it to check the request is really from us. |
The body is always JSON. The Content-Type is application/json.
{
"id": "0f8c2a1e-4b7d-4a9e-9c3f-2b1d5e6a7c88",
"type": "invoice.received",
"createdUtc": "2026-07-03T09:15:42.1234567Z",
"companyGuid": "8159a7b9-0770-4d27-afe3-e6eb0466d857",
"data": {
"invoiceGuid": "63c1964d-0d83-4f8d-b01d-57c94ba453fc"
}
}
Field names are camelCase.
| Field | Meaning |
|---|---|
id | Unique id of this delivery. Same as the X-Super-Webhook-Id header. |
type | The event type, for example invoice.received. |
createdUtc | When we built the payload (UTC). |
companyGuid | The GUID of your company that this event belongs to. |
data | Event data. For invoice.received it holds invoiceGuid. |
data.invoiceGuid | The GUID of the new invoice. Call GetInvoice with this value to read the full invoice. |
When you press "Send test event" in the app, we send a request in the same shape, but the data object is different:
{
"id": "...",
"type": "invoice.received",
"createdUtc": "...",
"companyGuid": "...",
"data": {
"test": true
}
}
So a real event has data.invoiceGuid. A test send has data.test = true.
Always verify the signature before you trust the request. This proves the request is from us and was not changed.
How we build the signature:
X-Super-Webhook-Timestamp header. It is Unix time in seconds, as text (for example 1790841600).{timestamp}.{body}: the timestamp, a dot, then the raw request body exactly as sent. Nothing is added between them or at the end. We turn the message into bytes as UTF-8.whsec_ prefix, turned into bytes as UTF-8. Do not remove the prefix and do not hex-decode the rest.sha256=. We send this whole string in the X-Super-Webhook-Signature header.To verify, do the same steps with the raw body you received and compare the whole string, including sha256=, with the X-Super-Webhook-Signature header.
CryptographicOperations.FixedTimeEquals.C# sample:
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
public static bool IsValidWebhook( string secret, string timestampHeader, string signatureHeader, string rawBody )
{
// 1) Reject old requests (replay protection).
if ( !long.TryParse( timestampHeader, NumberStyles.Integer, CultureInfo.InvariantCulture, out var unixSeconds ) )
{
return false;
}
var sentAt = DateTimeOffset.FromUnixTimeSeconds( unixSeconds );
if ( DateTimeOffset.UtcNow - sentAt > TimeSpan.FromMinutes( 5 ) )
{
return false;
}
// 2) Recompute the signature over "{timestamp}.{body}".
var payload = $"{timestampHeader}.{rawBody}";
using var hmac = new HMACSHA256( Encoding.UTF8.GetBytes( secret ) );
var hash = hmac.ComputeHash( Encoding.UTF8.GetBytes( payload ) );
var expected = "sha256=" + Convert.ToHexString( hash ).ToLowerInvariant( );
// 3) Constant-time compare.
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes( expected ),
Encoding.UTF8.GetBytes( signatureHeader ) );
}
Example to test your code. With these inputs you must get exactly this signature:
secret = whsec_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
timestamp = 1790841600
body = {"id":"3f2b8c1e-5a4d-4e7f-9b6a-1c2d3e4f5a6b","type":"invoice.received","createdUtc":"2026-10-01T08:00:00.1234567Z","companyGuid":"00000000-0000-0000-0000-000000000000","data":{"test":true}}
signature = sha256=5589e1d1060585e92c61edea0ec8917dbbecfac5e4af5bc24c3812788e8ee112
If you get a different result, you most likely removed whsec_ from the key or hex-decoded the key.
Return a 2xx status code as fast as you can (200, 201, 202, 204 all work). This tells us the delivery succeeded.
If you return any other status code, or your endpoint is slow or unreachable, we treat the delivery as failed and retry it.
If a delivery fails, we retry it. We wait longer after each try:
| After failed attempt | We wait |
|---|---|
| 1 | 1 minute |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
| 5 | 6 hours |
After 6 failed attempts the delivery becomes FailedFinal. We stop retrying that delivery automatically.
If one endpoint reaches FailedFinal 5 times in a row, we turn the endpoint off. We set a reason on the endpoint and send an email to your company. While the endpoint is off, we do not send new webhooks to it. Turn it back on in the app to resume; that also resets the failure counter.
A successful delivery resets the counter to zero.
The same webhook id can arrive more than once. This can happen after a retry, or when someone presses "Resend" in the app. A manual resend reuses the same id; it does not create a new one.
So process each id only once. Before you act, check if you already handled that id. If yes, answer 2xx and do nothing else. This keeps your side correct even when a webhook arrives twice.