Requirements in Masnet
Before configuring the receiving system, prepare the destination in Masnet. The steps are the same for every system.
- Open Company › Integrations (the Spanish interface labels it Empresa › Integraciones), go to the Destinations tab and select New destination. If the integration has no authorized data yet, Masnet asks you to authorize it first.
- In Destination address (https) enter the receiver public URL. It must be
https, port 443 or 8443 and a public DNS name: user information in the URL, fragments, IP addresses and internal names are not accepted. A query string is allowed; Masnet treats the whole URL as a credential. - In Format choose Standard JSON (
application/json) or Structured CloudEvents (application/cloudevents+json). The event content is the same; see Webhooks. - Select Create destination. Masnet shows the signing secret (
whsec_…) only once. Store it immediately in the receiver secret store; it cannot be read again. - Select Test destination and then Send test. The integration is activated only after a successful test; then use Activate destination.
Data the verifier needs
- The destination signing secret (
whsec_…), stored outside the code. - The exact body received, uninterpreted. In ASP.NET Core read
Request.Bodywith aStreamReaderbefore any model binding; do not deserialize first. - The
webhook-id,webhook-timestampandwebhook-signatureheaders. - A clock tolerance of 5 minutes, which is what the Standard Webhooks libraries apply and what is recommended for the receiver.
The signature is v1, followed by base64(HMAC-SHA256) computed with the binary key obtained by base64-decoding what follows whsec_, over the bytes webhook-id + . + webhook-timestamp + . + the exact body. Always verify over the received bytes, before parsing the JSON.
With the StandardWebhooks library
Install it with dotnet add package StandardWebhooks --version 1.1.2. It is a community package that uses Microsoft.AspNetCore.Http, so the project must be ASP.NET Core; the class in the package is StandardWebhook.
// dotnet add package StandardWebhooks --version 1.1.2 (requires the ASP.NET Core shared framework)
using Microsoft.AspNetCore.Http;
using StandardWebhooks;
using StandardWebhooks.Diagnostics;
public static class CSharpStandardWebhooks
{
/// <summary>
/// payload is the unparsed request body; Request.Headers carries the webhook-* headers; the library enforces a 5 minute tolerance.
/// The library only checks the first signature of the header, so each one is verified on its own (during a rotation the header carries two).
/// </summary>
public static bool Verify(string secret, string payload, IHeaderDictionary headers)
{
var webhook = new StandardWebhook(secret);
foreach (var signature in headers["webhook-signature"].ToString().Split(' ', System.StringSplitOptions.RemoveEmptyEntries))
{
var single = new HeaderDictionary
{
["webhook-id"] = headers["webhook-id"],
["webhook-timestamp"] = headers["webhook-timestamp"],
["webhook-signature"] = signature,
};
try
{
webhook.Verify(payload, single);
return true;
}
catch (WebhookVerificationException)
{
// try the next signature
}
}
return false;
}
}Without dependencies
The class compares in constant time and accepts any of the signatures in the header.
using System;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text;
/// <summary>Verifies a Masnet webhook with the base class library only. rawBody is the exact bytes received.</summary>
public static class CSharpManual
{
public static bool Verify(string secret, IDictionary<string, string> headers, byte[] rawBody, long nowSeconds, long tolerance = 300)
{
if (!headers.TryGetValue("webhook-id", out var id) || !headers.TryGetValue("webhook-timestamp", out var timestamp)
|| !headers.TryGetValue("webhook-signature", out var signatures) || !long.TryParse(timestamp, out var sent)) return false;
if (Math.Abs(nowSeconds - sent) > tolerance) return false;
var key = Convert.FromBase64String(secret.StartsWith("whsec_") ? secret.Substring(6) : secret);
using var hmac = new HMACSHA256(key);
var prefix = Encoding.UTF8.GetBytes($"{id}.{timestamp}.");
var signed = new byte[prefix.Length + rawBody.Length];
prefix.CopyTo(signed, 0);
rawBody.CopyTo(signed, prefix.Length);
var expected = hmac.ComputeHash(signed);
// During a rotation the header carries two signatures separated by a space; any match is valid.
foreach (var part in signatures.Split(' '))
{
var pair = part.Split(',', 2);
if (pair.Length != 2 || pair[0] != "v1") continue;
try
{
if (CryptographicOperations.FixedTimeEquals(Convert.FromBase64String(pair[1]), expected)) return true;
}
catch (FormatException) { /* ignore a malformed entry */ }
}
return false;
}
}Operation: duplicates and ordering
- Idempotency.
webhook-ididentifies the event and is kept across retries and redeliveries. Record the identifiers already processed and discard repeats with a 2xx response. - Ordering. Each resource (
subject) carries an increasingsequence. Discard an event whosesequenceis lower than or equal to the last one applied for thatsubject. Events of different resources have no order between them. - Retries. Masnet retries any non-2xx response for up to 72 hours; the full schedule is in Webhooks.
- Secret rotation. When rotating with overlap, the previous secret stays valid for 24 hours and
webhook-signaturecarries twov1,…signatures separated by a space. Accept the event if either one matches.
Troubleshooting
Masnet records the response code of each attempt in the delivery history. These are the effects of each receiver response:
| Receiver response | Effect in Masnet |
|---|---|
| 2xx | Successful delivery. It means receipt, not business processing. |
| 3xx | The redirect is not followed. It counts as a failure and is retried; the history shows the Location without its query string. |
| 400, 401, 403, 404 and other 4xx or 5xx | Retryable failure for up to 72 hours from the start of the delivery cycle. |
| 410 | Masnet disables the destination and cancels pending deliveries. Return 410 only if the destination must stop receiving events. |
| 413 | The event is marked as undeliverable to that destination and is not retried. |
429 or 503 with Retry-After | Retry-After (seconds or HTTP date) is honored up to a maximum of 1 hour. |
| No response within 15 seconds | Timeout (5 s to connect, 15 s in total). It is a retryable failure: answer 2xx and process asynchronously. |
| Verification fails only in production | Almost always the body was read after model binding. Read the original bytes. |