# 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](/guides/webhooks), with the additional `x-larm-endpoint-id` header.

## Installation webhooks

```json
{
  "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

```json
{
  "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:

```http
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:

```json
{"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:

```js
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 `/apps` prefix, even though the request is sent to `/apps/privacy/…`.
- The timestamp must be within 35 seconds of LARM's clock, and a `nonce` cannot 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.

> **Warning:** A request that passes its deadline without an acknowledgement is escalated to the LARM team, and can lead to the app being suspended.
