Webhooks
App uninstall & data deletion
Installation webhooks for uninstall, scope revocation and suspension, privacy webhooks for store and customer data deletion, and how to send the data-deletion acknowledgement.
Uninstall & privacy webhook URL
Separately from store webhooks, LARM sends installation webhooks and privacy webhooks to the lifecycle_privacy_url registered in your app version. The URL is registered when the version is approved.
The signing key for these webhooks is generated on the Access tab of the Developer Portal with the "Generate signing key" button, and is shown once. The previous key stays valid for 24 hours. Next to it is the endpoint ID, which is also sent with every webhook in the x-larm-endpoint-id header.
Signature, headers and retries work like store webhooks, with the additional x-larm-endpoint-id header.
Installation webhooks
{
"id": "e0c1…",
"type": "app.uninstalled",
"payload_version": 1,
"store_id": "5b1d7a62-…",
"app_id": "1f2e3d4c-…",
"installation_id": "c7e4a1f2-…",
"sequence": 7,
"data": { "event_type": "app.uninstalled", "app_auth_epoch": 2, "grant_revision": 3, "revoked_scopes": ["orders:read", "customers:read"], "data_deletion_requested": true },
"occurred_at": "2026-10-15T11:40:00Z"
}type | When | What to do |
|---|---|---|
app.uninstalled | The merchant uninstalled the app. Tokens and webhook subscriptions were revoked | Stop all work for this store and delete its tokens. Store data is deleted on shop.redact |
scopes.revoked | The merchant revoked optional scopes, along with the scopes that depend on them | Stop using them. The related endpoints return 403 |
app.suspended | LARM suspended the app | Stop sending requests until app.resumed |
app.resumed | The app was resumed | Resync from the API before relying on the data you hold |
reconciliation_required | Webhooks were lost before delivery | Run a full resync for the store |
sequence increases per installation. If you receive a number lower than one you have already processed, the webhook is stale.
Privacy webhooks
{
"id": "a9b8…",
"type": "shop.redact",
"payload_version": 1,
"privacy_request_id": "6d5c…",
"store_id": "5b1d7a62-…",
"app_id": "1f2e3d4c-…",
"installation_id": "c7e4a1f2-…",
"subject_ref": null,
"occurred_at": "2026-10-17T11:40:00Z"
}type | When | Required action | Deadline |
|---|---|---|---|
shop.redact | 48 hours after uninstall, or when the store is deleted | Delete all data you hold for this store | 30 days |
customers.data_request | A customer requested a copy of their data | Send the merchant what you hold about subject_ref | 30 days |
customers.redact | A customer requested deletion of their data | Delete what you hold about subject_ref | 30 days |
Send the data-deletion acknowledgement
A request is not closed until you acknowledge it. After deleting the data, send:
POST https://larm.sa/apps/privacy/requests/{privacy_request_id}/ack
Content-Type: application/json
x-larm-endpoint-id: {from the webhook header}
x-larm-event-id: {the webhook id}
x-larm-timestamp: {current Unix seconds}
x-larm-nonce: {43 random base64url characters}
x-larm-ack-signature: {hex}The body has exactly these keys, sorted alphabetically:
{"completed_at":"2026-10-18T08:00:00.000Z","event_id":"a9b8…","evidence_digest":"<64 hex>","installation_id":"c7e4a1f2-…","result_code":"DELETED","subject_ref":null}| Field | Value |
|---|---|
result_code | DELETED, NO_DATA or REQUIRES_REVIEW |
evidence_digest | Hex SHA-256 of your deletion record; keep the record so you can prove the deletion later |
The signature is hex HMAC-SHA256, keyed with the same uninstall & privacy signing key (base64url-decoded), over canonical JSON with sorted keys:
import crypto from 'node:crypto'
const canonical = value => Array.isArray(value) ? `[${value.map(canonical).join(',')}]`
: value && typeof value === 'object' ? `{${Object.keys(value).sort().map(k => `${JSON.stringify(k)}:${canonical(value[k])}`).join(',')}}`
: JSON.stringify(value)
function signAck({ requestId, eventId, endpointId, body, key }) {
const timestamp = String(Math.floor(Date.now() / 1000))
const nonce = crypto.randomBytes(32).toString('base64url')
const basis = canonical({
domain: 'larm-privacy-ack-v1', method: 'POST', path: `/privacy/requests/${requestId}/ack`,
requestId, eventId, endpointId, timestamp, nonce,
digest: crypto.createHash('sha256').update(body).digest('hex'),
})
return {
'x-larm-endpoint-id': endpointId, 'x-larm-event-id': eventId, 'x-larm-timestamp': timestamp, 'x-larm-nonce': nonce,
'x-larm-ack-signature': crypto.createHmac('sha256', key).update(basis).digest('hex'),
}
}- The signed path is
/privacy/requests/{id}/ack, without the/appsprefix, even though the request is sent to/apps/privacy/…. - The timestamp must be within 35 seconds of LARM's clock, and a
noncecannot be reused (REPLAY_DETECTED). - Sending the same acknowledgement again returns the same receipt; a different acknowledgement for the same request returns 409
ACK_CONFLICT. - Response:
{ request_id, event_id, acknowledgement_hash }. Store it.