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.
| Header | Purpose |
|---|---|
X-Signature | Timestamped HMAC-SHA256 over the raw body. Format: t=<unix>,v1=<hex>[,v1=<hex>...]. See Authentication → Computing the signature. |
X-TenantId | The 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-Id | Ties 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.
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 type | State | When it fires |
|---|---|---|
LoyaltyRuleChanged | Available | Merchant changed earn rules (points-per-riyal, stamp thresholds). Apply only when ruleVersion is greater than the version you last stored. |
IntegrationStatusChanged | Available | Merchant enabled, disabled, or disconnected the integration. Stop sending events after Disabled or Disconnected. |
RefundReversalCompleted | Planned | Reports the outcome of a refund reversal. Not emitted yet. |
RedemptionExpiredOrCancelled | Planned | A pending redemption expired or was cancelled. Not emitted yet. |
Full payload shapes for each are on the Events page and in the API Reference.