> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mixpanel.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Compliance Status Callbacks

> Receive a signed POST when a GDPR or CCPA request changes status

When you submit a data deletion or data retrieval request, Mixpanel can POST to your endpoint every time that request changes status, instead of you polling the status endpoint.

Callbacks follow the [OpenDSR](https://github.com/opengdpr/OpenDSR) specification: the field names, the status vocabulary and the signature scheme are the spec's. See [Limitations](#limitations) for where Mixpanel stops short of full conformance.

> [!NOTE]
>
> Callbacks are available on API version 3.0 only, and must be enabled for your project. Contact your Mixpanel representative to request access.

## Subscribing

Pass `status_callback_urls` when you create the request. Every URL you name receives its own copy of every callback.

```bash Create a deletion with callbacks theme={"system"}
curl --request POST \
  --url 'https://mixpanel.com/api/app/data-deletions/v3.0/?token=YOUR_PROJECT_TOKEN' \
  --header 'authorization: Bearer YOUR_GDPR_OAUTH_TOKEN' \
  --header 'content-type: application/json' \
  --data '{
    "distinct_ids": ["9f8c2d41-6b3e-4f0a-8d21-5c7a1e9b4f33"],
    "compliance_type": "GDPR",
    "subject_request_id": "3f2b8c14-9d7e-4a06-b153-2e8f7c4a9d51",
    "status_callback_urls": ["https://example.com/mixpanel/dsr-callback"]
  }'
```

| Field | Required | Notes |
| - | - | - |
| `status_callback_urls` | No | A JSON array of up to 5 HTTPS URLs. A non-HTTPS entry, or more than 5, is rejected with a 400. |
| `subject_request_id` | **Yes, whenever `status_callback_urls` is set** | Your own identifier for the request. Must be a lowercase canonical UUID v4. |

Mixpanel does not generate a `subject_request_id` for you. It must be unique within your project.

It is echoed on every callback and on the status endpoint's response. Correlate on it rather than on Mixpanel's `tracking_id`.

## The callback request

Mixpanel sends an HTTP `POST` with a JSON body and these headers:

| Header | Value |
| - | - |
| `Content-Type` | `application/json` |
| `X-OpenDSR-Processor-Domain` | `opendsr.mixpanel.com` |
| `X-OpenDSR-Signature` | Base64 RSA signature over the raw request body |

Return `202 Accepted` once the signature validates, and `403 Forbidden` if it does not, which is what OpenDSR §8.8 asks for. Mixpanel treats any `2xx` as delivered; any other response, a timeout, or a TLS failure is a failed delivery and is retried.

> [!WARNING]
>
> Redirects are not followed. The URL you register must accept the POST directly.

### Example callback

```http Completed retrieval theme={"system"}
POST /mixpanel/dsr-callback HTTP/1.1
Host: example.com
Content-Type: application/json
X-OpenDSR-Processor-Domain: opendsr.mixpanel.com
X-OpenDSR-Signature: TWl4cGFuZWwgc2lnbmF0dXJlLCBiYXNlNjQtZW5jb2RlZA==

{
  "api_version": "3.0",
  "controller_id": "2195832",
  "expected_completion_time": "2026-10-23T14:22:07.318402Z",
  "extensions": {
    "opendsr.mixpanel.com": {
      "project_id": 2195832,
      "status": "completed",
      "tracking_id": "7c4e1a90-3b52-4d68-9f01-6a8d2e5b7c43"
    }
  },
  "request_status": "completed",
  "results_url": "https://storage.googleapis.com/mixpanel-compliance-exports/...",
  "status_callback_url": "https://example.com/mixpanel/dsr-callback",
  "subject_request_id": "3f2b8c14-9d7e-4a06-b153-2e8f7c4a9d51",
  "subject_request_type": "portability"
}
```

| Field | Type | Notes |
| - | - | - |
| `api_version` | string | Always `3.0`. Mixpanel's DSR API version, which is not the discovery document's `api_version`. |
| `controller_id` | string | Your Mixpanel project ID, as a string. |
| `expected_completion_time` | string | RFC 3339. The request's creation time plus 30 days — the response window, restated per request. It does not move between callbacks. |
| `subject_request_id` | string | The identifier you supplied at creation. |
| `subject_request_type` | string | `erasure` for a deletion. `access` for a retrieval, or `portability` for one whose disclosure type is Data. |
| `request_status` | string | One of `pending`, `in_progress`, `completed`, `cancelled`. See [Status vocabulary](#status-vocabulary). |
| `status_callback_url` | string | The URL this callback was addressed to. |
| `results_url` | string or null | The signed export link on a completed retrieval, valid for just under 30 days from completion. Re-reading the status endpoint does not extend it. `null` on deletions, on any non-completed status, and on a retrieval whose export has aged out. |
| `extensions` | object | Mixpanel-specific fields, keyed by `opendsr.mixpanel.com`. |

Every field is always present. `results_url` is `null` rather than omitted when it does not apply.

#### Extensions

Everything under `extensions` is Mixpanel's own, not part of OpenDSR. A subscriber that ignores this object entirely still works.

| Field | Type | Notes |
| - | - | - |
| `tracking_id` | string | Mixpanel's identifier for the request, returned as `task_id` when you created it. Stable across internal retries. |
| `project_id` | number | The project ID, as a number. |
| `status` | string | The unmapped status. Identical to `request_status` except on a failure, where this reads `failed`. |

## Verifying the signature

> [!WARNING]
>
> Verify against the **raw request body**, before anything parses it.
>
> Mixpanel signs the exact bytes it sends. A body that has been parsed and re-serialized is not those bytes and will not verify.

Most frameworks parse the body before your handler runs. Use `request.get_data()` in Flask, `express.raw()` in Express, `request.body` in Django, or a `ContentCachingRequestWrapper` in Spring.

First, reject the callback unless `X-OpenDSR-Processor-Domain` is exactly `opendsr.mixpanel.com`. Compare that header against a value you have hardcoded — never treat it as an instruction about which certificate to fetch.

`X-OpenDSR-Signature` is a base64-encoded RSA signature (PKCS#1 v1.5, SHA-256) over the raw body. Verify it against the public key from Mixpanel's certificate.

Fetch the certificate once from `https://opendsr.mixpanel.com/v1/certificate.pem` and store it; do not fetch it per callback. When you fetch it, confirm it chains to a trusted authority, was issued to `opendsr.mixpanel.com`, and has not expired.

```python Python theme={"system"}
import base64
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.exceptions import InvalidSignature
from cryptography import x509

with open("opendsr-mixpanel-com.pem", "rb") as f:
    public_key = x509.load_pem_x509_certificate(f.read()).public_key()


def verify(raw_body: bytes, signature_header: str) -> bool:
    try:
        public_key.verify(
            base64.b64decode(signature_header),
            raw_body,
            padding.PKCS1v15(),
            hashes.SHA256(),
        )
        return True
    except InvalidSignature:
        return False
```

```javascript Node.js theme={"system"}
const crypto = require("crypto");
const fs = require("fs");

const certificate = fs.readFileSync("opendsr-mixpanel-com.pem");
const publicKey = new crypto.X509Certificate(certificate).publicKey;

function verify(rawBody, signatureHeader) {
  return crypto
    .createVerify("RSA-SHA256")
    .update(rawBody)
    .verify(publicKey, signatureHeader, "base64");
}
```

After verifying the signature, confirm that `status_callback_url` in the body matches the endpoint the request arrived on. OpenDSR includes this step so that a valid callback captured from one of your endpoints cannot be replayed against another.

### Certificate renewal

Mixpanel's certificate is reissued periodically, but **the keypair does not change across renewals**. Extract and pin the public key rather than the certificate, and renewals require no action from you. If you pin the certificate itself, refresh your copy before it expires.

> [!NOTE]
>
> There is no timestamp or nonce in the body, so a captured callback and its signature stay valid indefinitely. A retrieval callback carries a working `results_url` — treat a stored body as sensitive and keep it out of your logs.

## Status vocabulary

`request_status` is always one of OpenDSR's four values. Branch on this field.

| `request_status` | Meaning |
| - | - |
| `pending` | Accepted, not yet started. |
| `in_progress` | Running. |
| `completed` | Finished successfully. On a retrieval, `results_url` is populated. |
| `cancelled` | Final, without asserting the request was fulfilled. |

OpenDSR defines no failure status. A request that errors reports `cancelled` — the only terminal value that does not assert fulfilment — and `extensions["opendsr.mixpanel.com"].status` reads `failed`. Check that field if you need to distinguish a genuine cancellation from an error.

> [!WARNING]
>
> These are not the values the status endpoint returns. `GET /data-deletions/v3.0/{tracking_id}` reports Mixpanel's internal task states — `PENDING`, `STAGING`, `STARTED`, `SUCCESS`, `FAILURE`, `REVOKED` — while callbacks report OpenDSR's four. If you consume both, map them separately rather than assuming one vocabulary.

The first callback arrives shortly after you create the request, while it is still `pending`. Use it to confirm your endpoint is reachable and your signature verification works against a real body.

## Delivery semantics

**At-least-once.** A retry sends the identical body with the identical signature. Deduplicate on the body, or on the pair of `subject_request_id` and `request_status`. An exactly-once assumption will double-process.

**Retry cadence.** Deliveries are attempted every 15 minutes, which is also how quickly a status change reaches a healthy endpoint. A failed delivery is retried on the next cycle.

**When Mixpanel stops.** Retries stop 90 days after the request was created — longer than the 30-day `expected_completion_time`, so a request that misses its deadline still delivers its completion callback. After 90 days you receive nothing further.

The status endpoint remains the source of truth throughout. If a callback you expected has not arrived, poll it.

## Discovery

Mixpanel publishes an OpenDSR discovery document at `https://opendsr.mixpanel.com/v1/discovery`. It needs no authentication.

```json GET /v1/discovery theme={"system"}
{
  "api_version": "2.0",
  "supported_subject_request_types": ["access", "erasure", "portability"],
  "supported_identities": [
    {
      "identity_type": "controller_customer_id",
      "identity_format": "raw"
    }
  ],
  "processor_certificate": "https://opendsr.mixpanel.com/v1/certificate.pem"
}
```

> [!WARNING]
>
> `api_version` in this context does not carry the same meaning as in a callback body.
>
> Here, it's the version of the OpenDSR specification Mixpanel implements. In a callback, it is Mixpanel's own DSR API version, which is why the two differ.

`controller_customer_id` with format `raw` is the identity type Mixpanel accepts: it corresponds to the `distinct_id` you already use. The other identity types OpenDSR lists are device and advertising identifiers, which Mixpanel does not resolve requests by.

## Limitations

Mixpanel's callbacks are **OpenDSR-aligned**, not a full OpenDSR implementation. Field names, status vocabulary, signing and discovery follow the specification, but Mixpanel does not serve `POST /v1/requests`. Create requests through the endpoints above instead.
