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 + "." + payload and returns a base64 signature; the SMS API signs the raw body only and returns a hex sha256= 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 over timestamp + "." + 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 CodeStatus NameReason
429Too Many RequestsYour endpoint is rate limiting — retry after backoff.
500Internal Server ErrorTemporary server issue on your side.
503Service UnavailableYour 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 CodeStatus NameExample Reason
400Bad RequestInvalid payload format.
401UnauthorizedInvalid or missing authentication.
403ForbiddenInsufficient permissions.
404Not FoundWebhook endpoint doesn't exist.
405Method Not AllowedEndpoint doesn't accept POST.
422Unprocessable EntityValidation error in the payload.
Any other non-2xxTreated 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 OK for 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 2xx status 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.


Did this page help you?