Webhooks

Quroosh delivers outbound events to your registered webhook URL. The request body is the same event envelope you post to Quroosh — read eventType to know which payload you received. Every delivery is signed with HMAC-SHA256 using the same signing secret you use for outbound requests.

Registering a receiver

Your receiver URLs — one per deployment you run (testing, production) — are registered with Quroosh once, when the partnership is set up; merchants never enter a URL. At activation the merchant picks which of your deployments to target, and every delivery for that merchant goes to the matching URL. The receiver is shared across your tenants: read theX-TenantId header to know which tenant a delivery belongs to. The URL must be HTTPS and respond with 2xx promptly. Failures are retried on a backoff ladder (below); a delivery that exhausts the ladder is marked failed and surfaced on the merchant's integration dashboard.

Delivery headers

Three headers accompany every delivery. Everything else you need —eventId, eventType, correlationId — is inside the JSON body.

HeaderPurpose
X-SignatureTimestamped HMAC-SHA256 over the raw body. Format: t=<unix>,v1=<hex>[,v1=<hex>...]. See Authentication → Computing the signature.
X-TenantIdThe merchant's tenant identifier on your platform, exactly as entered at activation. Use it to route the delivery to the right tenant, then verify the signature against that tenant's secret.
X-Correlation-IdTies this delivery to the business action that produced it. Log it for support.

Verifying the signature

Verify every incoming request before trusting the body. Use a constant-time comparison to avoid timing attacks.

using System.IO;
using System.Security.Cryptography;
using System.Text;

app.MapPost("/webhooks/quroosh", async (HttpContext ctx) =>
{
    // Read the raw request bytes — do NOT rely on JSON model binding here.
    using var buffer = new MemoryStream();
    await ctx.Request.Body.CopyToAsync(buffer);
    var rawBody = buffer.ToArray();

    var header = ctx.Request.Headers["X-Signature"].ToString();
    if (!TryParse(header, out var timestamp, out var v1List))
    {
        ctx.Response.StatusCode = 401;
        return;
    }

    // Reject stale or future-dated requests (±5 minutes).
    var age = DateTimeOffset.UtcNow - DateTimeOffset.FromUnixTimeSeconds(timestamp);
    if (Math.Abs(age.TotalSeconds) > 300)
    {
        ctx.Response.StatusCode = 401;
        return;
    }

    // Compute expected HMAC over "{timestamp}.{raw_body}".
    using var hmac = IncrementalHash.CreateHMAC(
        HashAlgorithmName.SHA256,
        Encoding.UTF8.GetBytes(signingSecret));
    hmac.AppendData(Encoding.UTF8.GetBytes($"{timestamp}."));
    hmac.AppendData(rawBody);
    var expected = hmac.GetHashAndReset();

    // Constant-time compare against every candidate signature.
    var ok = v1List.Any(hex =>
        CryptographicOperations.FixedTimeEquals(expected, Convert.FromHexString(hex)));
    if (!ok)
    {
        ctx.Response.StatusCode = 401;
        return;
    }

    // ... deserialize rawBody and process event ...
    ctx.Response.StatusCode = 200;
});

// "t=1752573871,v1=abcd...,v1=1234..." → (timestamp, [hex, hex])
static bool TryParse(string header, out long timestamp, out List<string> v1s)
{
    timestamp = 0;
    v1s = new List<string>();
    var hasT = false;
    foreach (var part in header.Split(','))
    {
        var t = part.AsSpan().Trim();
        if (t.StartsWith("t=") && long.TryParse(t[2..], out timestamp)) hasT = true;
        else if (t.StartsWith("v1=") && t.Length > 3) v1s.Add(t[3..].ToString());
    }
    return hasT && v1s.Count > 0;
}

Retries

A 5xx, a 429, a timeout, or a network failure is retried on a backoff ladder: the first attempt is immediate, then1 min, 5 min, 15 min, 1 hr, 6 hr. After the sixth attempt the delivery is marked failed and surfaced on the merchant's integration dashboard. A 4xx is treated as a permanent decision and is not retried — the one exception is a 401 whose body carriesSIGNATURE_EXPIRED, which retries so a clock skew can recover.

Deduplicate on the body's eventId

Retries carry the same eventId inside the JSON envelope. Your handler must be idempotent — the simplest approach is a unique index on(event_id) in your inbox table.

Event catalog

Each delivery is an event envelope; the payload shapes are documented on the Events page. What Quroosh sends today:

Event typeStateWhen it fires
LoyaltyRuleChangedAvailableMerchant changed earn rules (points-per-riyal, stamp thresholds). Apply only when ruleVersion is greater than the version you last stored.
IntegrationStatusChangedAvailableMerchant enabled, disabled, or disconnected the integration. Stop sending events after Disabled or Disconnected.
RefundReversalCompletedPlannedReports the outcome of a refund reversal. Not emitted yet.
RedemptionExpiredOrCancelledPlannedA pending redemption expired or was cancelled. Not emitted yet.

Full payload shapes for each are on the Events page and in the API Reference.