> ## 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.
</Note>

## 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.
</Warning>

### 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.
</Warning>

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.

<CodeGroup>
  ```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");
  }
  ```
</CodeGroup>

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.
</Note>

## 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.
</Warning>

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.
</Warning>

`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.
