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 Spring receive the body as
byte[]orStringwith@RequestBody, not as an already deserialized object. - 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
Add the Maven dependency com.standardwebhooks:standardwebhooks.
// Maven: com.standardwebhooks:standardwebhooks:1.2.0
import com.standardwebhooks.Webhook;
import com.standardwebhooks.exceptions.EmptyWebhookSecretException;
import com.standardwebhooks.exceptions.WebhookVerificationException;
import java.util.List;
import java.util.Map;
/** `payload` is the unparsed request body; the library enforces a 5 minute tolerance. */
public final class JavaStandardWebhooks {
public static boolean verify(String secret, String payload, Map<String, List<String>> headers) {
try {
new Webhook(secret).verify(payload, headers);
return true;
} catch (WebhookVerificationException e) {
return false;
} catch (EmptyWebhookSecretException e) {
throw new IllegalStateException("The webhook secret is not configured", e);
}
}
}Without dependencies
The class compares in constant time and accepts any of the signatures in the header.
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Base64;
import java.util.Map;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
/** Verifies a Masnet webhook with the JDK only. `rawBody` is the exact bytes received. */
public final class JavaManual {
public static boolean verify(String secret, Map<String, String> headers, byte[] rawBody, long nowSeconds, long tolerance) {
String id = headers.get("webhook-id");
String timestamp = headers.get("webhook-timestamp");
String signatures = headers.get("webhook-signature");
if (id == null || signatures == null || timestamp == null || !timestamp.matches("\\d+")) return false;
if (Math.abs(nowSeconds - Long.parseLong(timestamp)) > tolerance) return false;
try {
byte[] key = Base64.getDecoder().decode(secret.replaceFirst("^whsec_", ""));
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key, "HmacSHA256"));
mac.update((id + "." + timestamp + ".").getBytes(StandardCharsets.UTF_8));
byte[] expected = mac.doFinal(rawBody);
// During a rotation the header carries two signatures separated by a space; any match is valid.
for (String part : signatures.split(" ")) {
String[] pair = part.split(",", 2);
if (pair.length != 2 || !pair[0].equals("v1")) continue;
try {
if (MessageDigest.isEqual(Base64.getDecoder().decode(pair[1]), expected)) return true;
} catch (IllegalArgumentException malformed) {
// ignore a malformed entry and keep looking
}
}
} catch (Exception e) {
return false;
}
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 reserialized before verifying. Use the original bytes. |