Webhook Delivery Service
Overview
The Webhook Delivery Service delivers Optimove webhook events to your endpoint with HMAC signature verification. This guide explains how to verify webhook signatures, protect against replay attacks, and handle the HTTP response codes the service acts on.
Every webhook is signed so you can confirm it came from Optimove and hasn't been tampered with in transit. Verifying the signature — and the timestamp bound into it — is the core of a secure integration.
This is a different signature scheme from the SMS API webhooks. The Webhook Delivery Service signs
timestamp + "." + payloadand returns a base64 signature; the SMS API signs the raw body only and returns a hexsha256=signature. Don't reuse verification code between the two.
Authenticating Webhooks
How Webhooks Are Signed
When Optimove delivers a webhook to your endpoint, it includes two headers:
X-Hub-Signature— the HMAC-SHA256 signature of the timestamp and payload combined, base64-encoded.X-Hub-Signature-Timestamp— the Unix timestamp (in seconds) when the webhook was sent.
The signature is computed overtimestamp + "." + payload. Binding the signature to both the payload and the send time is what prevents replay attacks: an attacker can't alter the timestamp without invalidating the signature.
You'll need your HMAC secret key, provided by Optimove during setup, to verify each request.
Verifying the Signature
Step 1 — Extract the headers
var signature = Request.Headers["X-Hub-Signature"];
var timestamp = Request.Headers["X-Hub-Signature-Timestamp"];Step 2 — Read the raw request body
Read the body exactly as received, before any JSON parsing — parsing and re-serializing can change the bytes and break the signature.
string payload;
using (var reader = new StreamReader(Request.Body))
{
payload = await reader.ReadToEndAsync();
}Step 3 — Compute the expected HMAC
Compute the HMAC-SHA256 over timestamp + "." + payload using your secret key, then base64-encode the result.
using System.Security.Cryptography;
using System.Text;
public string ComputeHmac(string timestamp, string payload, string secretKey)
{
// Combine timestamp and payload with a period separator
var timestampedPayload = $"{timestamp}.{payload}";
var encoding = new UTF8Encoding();
var keyBytes = encoding.GetBytes(secretKey);
var payloadBytes = encoding.GetBytes(timestampedPayload);
using (var hmac = new HMACSHA256(keyBytes))
{
var hashBytes = hmac.ComputeHash(payloadBytes);
return Convert.ToBase64String(hashBytes);
}
}Step 4 — Compare signatures
var expectedSignature = ComputeHmac(timestamp, payload, yourSecretKey);
if (signature == expectedSignature)
{
// Signature is valid — process the webhook
}
else
{
// Invalid signature — reject the request
return BadRequest("Invalid signature");
}Step 5 — Verify the timestamp (replay-attack prevention)
Because the timestamp is part of the signature, checking that it's recent gives strong protection against replays. Reject anything older than a few minutes.
var webhookTime = DateTimeOffset.FromUnixTimeSeconds(long.Parse(timestamp));
var timeDifference = DateTimeOffset.UtcNow - webhookTime;
if (timeDifference.TotalMinutes > 5)
{
// Webhook is too old — reject it
return BadRequest("Webhook timestamp is too old");
}Complete Verification Example
[HttpPost("webhook")]
public async Task<IActionResult> ReceiveWebhook()
{
// Extract headers
var signature = Request.Headers["X-Hub-Signature"].ToString();
var timestamp = Request.Headers["X-Hub-Signature-Timestamp"].ToString();
// Read the raw payload
string payload;
using (var reader = new StreamReader(Request.Body))
{
payload = await reader.ReadToEndAsync();
}
// Retrieve your HMAC secret from secure storage
var secretKey = await GetHmacSecret();
// Verify signature (includes timestamp in the calculation)
var expectedSignature = ComputeHmac(timestamp, payload, secretKey);
if (signature != expectedSignature)
{
_logger.LogWarning("Invalid webhook signature received");
return BadRequest("Invalid signature");
}
// Verify timestamp (replay-attack prevention)
var webhookTime = DateTimeOffset.FromUnixTimeSeconds(long.Parse(timestamp));
if ((DateTimeOffset.UtcNow - webhookTime).TotalMinutes > 5)
{
_logger.LogWarning("Webhook timestamp is too old");
return BadRequest("Webhook too old");
}
// Acknowledge first, then process
var eventData = JsonSerializer.Deserialize<EventMessage>(payload);
await ProcessEvent(eventData);
return Ok();
}Responding to Webhooks
The service acts on your HTTP response code. Codes fall into three groups: success, retryable, and non-retryable.
Success (2xx)
Return any 2xx status code to confirm the webhook was received and processed. The service marks the message as delivered and takes no further action.
Retryable Codes
These indicate a temporary problem on your side. The service retries delivery with exponential backoff; if all retries are exhausted, the message goes to a dead letter queue (DLQ).
| Status Code | Status Name | Reason |
|---|---|---|
429 | Too Many Requests | Your endpoint is rate limiting — retry after backoff. |
500 | Internal Server Error | Temporary server issue on your side. |
503 | Service Unavailable | Your service is temporarily down or overloaded. |
Non-Retryable Codes
All other non-2xx codes are treated as permanent failures. Delivery is aborted and the message is sent to the DLQ — it is not retried.
| Status Code | Status Name | Example Reason |
|---|---|---|
400 | Bad Request | Invalid payload format. |
401 | Unauthorized | Invalid or missing authentication. |
403 | Forbidden | Insufficient permissions. |
404 | Not Found | Webhook endpoint doesn't exist. |
405 | Method Not Allowed | Endpoint doesn't accept POST. |
422 | Unprocessable Entity | Validation error in the payload. |
| Any other non-2xx | — | Treated as non-retryable. |
Best Practices
Acknowledge Quickly
Acknowledge receipt before you process the payload. A request that isn't acknowledged within 10 seconds is treated as a timeout and retried (up to 5 times) before being dropped. Process asynchronously if the work is slow, and avoid long-running operations in the handler.
Return the Right Status Code
- Return 2xx for successful processing.
- Return 429, 500, or 503 only for temporary issues that should be retried.
- Return 4xx (other than 429) for permanent errors that should not be retried.
Handle Idempotency
Because webhooks may be retried, design your handler to process each event once:
- Store a unique identifier from the payload to detect duplicates.
- Process each unique webhook only once.
- Return
200 OKfor duplicates without reprocessing.
Error Handling Pattern
[HttpPost("webhook")]
public async Task<IActionResult> ReceiveWebhook()
{
try
{
// Verify signature (as shown above), then process
await ProcessWebhook(payload);
return Ok(); // 200 — success
}
catch (ValidationException ex)
{
// Permanent error — don't retry
_logger.LogWarning(ex, "Invalid webhook payload");
return BadRequest(ex.Message); // 400 — non-retryable
}
catch (TemporaryException ex)
{
// Temporary error — allow retry
_logger.LogError(ex, "Temporary processing error");
return StatusCode(503, "Service temporarily unavailable"); // 503 — retryable
}
}Troubleshooting
Webhooks not being received
- Confirm your webhook URL is configured correctly with Optimove.
- Check that your endpoint is reachable from the internet and your firewall allows inbound POST requests.
- Verify your SSL/TLS certificate is valid.
Signature verification failing
- Confirm you're using the correct HMAC secret.
- Compute the HMAC over the raw request body (before parsing).
- Use HMAC-SHA256 and base64-encode the result.
- Ensure you include the timestamp in the signed value:
timestamp + "." + payload.
Webhooks being retried unexpectedly
- Confirm you return a
2xxstatus code on success. - Verify your endpoint responds within the acknowledgment timeout.
- Review logs for temporary errors that trigger a retry.
Messages going to the DLQ
- For retryable errors: investigate why the retries were exhausted.
- For non-retryable errors: fix the endpoint issue, then replay from the DLQ.
Updated about 2 hours ago