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

# Inbound URL

> A secret URL for sign-up events: Privy, Dynamic or your own backend post new users and wallets to it, and CabalSpy ID creates or updates the platform users automatically.

Instead of calling the API on every sign-up, you can give your auth provider or your backend a secret **inbound URL**. Every sign-up or wallet event posted to it creates or updates the [platform user](https://docs.cabalspy.xyz/cabalspy-id/white-label.md), exactly like [Create or update a user](https://docs.cabalspy.xyz/cabalspy-id/platform-users-create.md).

It works with the webhooks of Privy, Dynamic and any other provider that sends JSON, and with your own format. No API key is needed: the secret in the URL identifies your instance.

In the dashboard the inbound URL is the **Sign-up URL** on the instance's [White-label](https://docs.cabalspy.xyz/cabalspy-id/dashboard.md#white-label) tab: **Switch on** creates it, **Create a new URL** replaces it and **Switch off** disables it, like the calls below.

> [!WARNING]
>
> Keep the URL secret. Whoever knows it can create users in your instance. If it leaks, enable it again to get a new URL, the old one stops working at once.

## Enable

`POST /v1/platform-users/inbound/enable`

Creates the secret URL and returns it. It is shown only once. Calling it again replaces the URL.

```bash cURL theme={null}
curl -X POST "https://api-id.cabalspy.xyz/v1/platform-users/inbound/enable" \
  -H "Authorization: Bearer YOUR_KEY"
```

```python Python theme={null}
import requests

r = requests.post("https://api-id.cabalspy.xyz/v1/platform-users/inbound/enable",
                  headers={"Authorization": "Bearer YOUR_KEY"})
print(r.json()["data"]["url"])   # store it as a secret
```

```javascript JavaScript theme={null}
const res = await fetch("https://api-id.cabalspy.xyz/v1/platform-users/inbound/enable", {
  method: "POST",
  headers: { Authorization: "Bearer YOUR_KEY" },
});
const { data } = await res.json();
console.log(data.url);
```

```json Response theme={null}
{
  "success": true,
  "data": {
    "enabled": true,
    "url": "https://api-id.cabalspy.xyz/v1/inbound/csin_r8Tq2...",
    "note": "Keep this URL secret: whoever knows it can create users in your instance. Calling this again replaces it."
  },
  "meta": { "request_id": "req_5162c7cf7f87", "cached": false, "cache_age_seconds": 0, "version": "v1", "timestamp": "2026-10-26T08:04:11Z" }
}
```

## Status

`GET /v1/platform-users/inbound`

```bash theme={null}
curl "https://api-id.cabalspy.xyz/v1/platform-users/inbound" \
  -H "Authorization: Bearer YOUR_KEY"
```

```json Response theme={null}
{
  "success": true,
  "data": {
    "enabled": true,
    "last_event_at": "2026-10-26T07:58:02Z",
    "formats": ["csid", "privy", "dynamic", "generic"]
  },
  "meta": { "request_id": "req_5162c7cf7f87", "cached": false, "cache_age_seconds": 0, "version": "v1", "timestamp": "2026-10-26T08:04:11Z" }
}
```

The URL itself is never shown again. `last_event_at` is the time of the last event received.

## Disable

`POST /v1/platform-users/inbound/disable`

Switches the URL off. Returns `{"enabled": false}`. Events posted to it afterwards get `404 invalid_inbound_url`.

## Send events

`POST https://api-id.cabalspy.xyz/v1/inbound/csin_...`

Post one event per request as a JSON object. The event is read like this:

* **User id**: the first of `external_id`, `user.id`, `data.userId`, `userId`, `data.user_id`, `user_id` and `data.id` that is set. It becomes the `external_id`.
* **Event name**: `event`, `type` or `eventName`.
* **Wallets**: the `wallets` array if the body has one (CabalSpy ID format, with `chains` and `source`). Otherwise every Solana or EVM address found anywhere in the body in a field named `address`, `wallet_address`, `walletAddress`, `publicKey` or `public_key`. Other values, such as e-mail addresses, are ignored.
* **Profile**: `display_name` and `avatar_url` at the top level, if present.

Then:

| Event | Result |
| - | - |
| Name contains `delet` (and not `wallet`), for example `user.deleted` | The user is [deleted](https://docs.cabalspy.xyz/cabalspy-id/platform-users-delete.md). |
| `wallet.removed` in the CabalSpy ID format, with `wallets` | Those wallets are [removed](https://docs.cabalspy.xyz/cabalspy-id/platform-users-wallets.md#remove-a-wallet): `wallets_removed`. The user's last wallet stays and is listed in `wallets_not_removed` with `last_wallet`. |
| Any other name that contains `unlink`, `remov` or `delet`, for example Privy `user.unlinked_account` or Dynamic `wallet.unlinked` | Nothing changes: `ignored`. |
| Unknown user, no wallet in the body, for example an e-mail sign-up before the embedded wallet exists | Nothing yet: `waiting_for_wallet`. The next event with a wallet creates the user. |
| Anything else | The user is created, or updated with new wallets and profile fields. |

> [!NOTE]
>
> Wallet removals from auth providers are not applied, because their payloads usually also carry the wallets the user keeps. To remove a wallet, send `{"event": "wallet.removed", "external_id": "...", "wallets": ["<address>"]}` to the inbound URL, or call [POST /v1/platform-users/wallets/delete](https://docs.cabalspy.xyz/cabalspy-id/platform-users-wallets.md#remove-a-wallet).

### CabalSpy ID format

Use this from your own backend.

```json theme={null}
{
  "event": "user.created",
  "external_id": "user_8412",
  "display_name": "Maze",
  "avatar_url": "https://cdn.example.com/avatars/8412.png",
  "wallets": [
    { "address": "7j7AA3HZR2zEjwAQEKPFh2qucLY4fqZpB9iodf39w8xW", "source": "embedded" },
    { "address": "0x4b1e9c0d2a7f3e5b8c6d1a9f0e2b7c4d5a6e8f01", "chains": ["base"] }
  ]
}
```

`event` is one of `user.created`, `user.updated`, `wallet.added` and `user.deleted`. `wallets` takes the same items as [Create or update a user](https://docs.cabalspy.xyz/cabalspy-id/platform-users-create.md#body).

### Privy

Add the inbound URL as a webhook endpoint in the Privy dashboard and subscribe to the user created and wallet created events. The Privy user id (`user.id`, `did:privy:...`) becomes the `external_id`, and the wallet addresses in `linked_accounts` and `wallet` are added.

```json Privy user.created theme={null}
{
  "type": "user.created",
  "user": {
    "id": "did:privy:cm3np4u9j001rc8b73seqmqqk",
    "linked_accounts": [
      { "type": "email", "address": "maze@example.com" },
      { "type": "wallet", "address": "7j7AA3HZR2zEjwAQEKPFh2qucLY4fqZpB9iodf39w8xW", "chain_type": "solana" }
    ]
  }
}
```

### Dynamic

Add the inbound URL as a webhook in the Dynamic dashboard and subscribe to user creation and wallet linking. The top-level `userId` becomes the `external_id`; every `address` or `publicKey` is added.

```json Dynamic user.created theme={null}
{
  "eventName": "user.created",
  "userId": "8b1f2c4d-6e7a-4b9c-8d0e-1f2a3b4c5d6e",
  "data": {
    "id": "8b1f2c4d-6e7a-4b9c-8d0e-1f2a3b4c5d6e",
    "verifiedCredentials": [
      { "format": "blockchain", "chain": "eip155", "address": "0x4b1e9c0d2a7f3e5b8c6d1a9f0e2b7c4d5a6e8f01" }
    ]
  }
}
```

### Any other provider

Any JSON object works as long as it carries the user id in one of the fields above and the wallets in address-like fields.

```json Generic theme={null}
{
  "type": "signup",
  "userId": "u_8412",
  "wallet": { "publicKey": "7j7AA3HZR2zEjwAQEKPFh2qucLY4fqZpB9iodf39w8xW" }
}
```

### Example

```bash theme={null}
curl -X POST "https://api-id.cabalspy.xyz/v1/inbound/csin_r8Tq2..." \
  -H "Content-Type: application/json" \
  -d '{"event": "user.created", "external_id": "user_8412", "wallets": ["7j7AA3HZR2zEjwAQEKPFh2qucLY4fqZpB9iodf39w8xW"]}'
```

## Response

Every event that reaches your instance is answered with **200**, also when it is rejected for a business reason, such as a missing user id, an invalid address or your plan's user limit. Providers retry deliveries that are not answered with 2xx, so this keeps them from retrying events that would fail again. Check `accepted`.

**accepted** `boolean`

Whether the event was processed.

**result** `string`

`created`, `updated`, `deleted`, `wallets_removed`, `ignored` (a wallet removal from an auth provider), `unknown_user` (deletion or wallet removal for a user you do not have) or `waiting_for_wallet`.

**external\_id** `string`

The user id read from the event.

**csid\_id** `string`

created and updated: the user's CabalSpy ID account id.

**wallets\_added** `array`

created and updated: the wallets and chains that are new.

**wallets\_removed, wallets\_not\_removed** `array`

wallets\_removed: the addresses removed. wallets\_not\_removed: `{address, code}` for each one kept, for example `last_wallet` or `wallet_not_found`.

**deleted, unlinked** `boolean`

deleted: as in [Delete a user](https://docs.cabalspy.xyz/cabalspy-id/platform-users-delete.md#response).

**error** `object`

When accepted is false: code and message, the same codes as [Create or update a user](https://docs.cabalspy.xyz/cabalspy-id/platform-users-create.md#errors).

```json Created theme={null}
{
  "success": true,
  "data": {
    "accepted": true,
    "result": "created",
    "external_id": "user_8412",
    "csid_id": "3f9a1c2e-5b7d-4e8f-a1b2-c3d4e5f60718",
    "wallets_added": [
      { "address": "7j7AA3HZR2zEjwAQEKPFh2qucLY4fqZpB9iodf39w8xW", "family": "solana", "chains": ["solana"], "source": null }
    ]
  },
  "meta": { "request_id": "req_5162c7cf7f87", "cached": false, "cache_age_seconds": 0, "version": "v1", "timestamp": "2026-10-26T08:04:11Z" }
}
```

```json Rejected theme={null}
{
  "success": true,
  "data": {
    "accepted": false,
    "error": {
      "code": "plan_user_limit",
      "message": "Your Starter plan allows 200 users and all of them are taken (200 on example.com). Upgrade the plan to add more users."
    }
  },
  "meta": { "request_id": "req_5162c7cf7f87", "cached": false, "cache_age_seconds": 0, "version": "v1", "timestamp": "2026-10-26T08:04:11Z" }
}
```

## Rate limit and retries

The inbound URL accepts **600 events per minute** per instance, counted in fixed one-minute windows. Above that, events are not processed and answered with **429** `rate_limited`. Your provider retries them on its own schedule (Privy and Dynamic retry failed deliveries with a backoff). Retries are safe: an event creates or updates the user, so a repeated event changes nothing.

When you send events from your own backend, retry 429 answers after the seconds in their `Retry-After` header (the next full minute).

Other non-2xx answers:

| Status | Code | Meaning |
| - | - | - |
| 400 | invalid\_body | The body is not JSON. |
| 404 | invalid\_inbound\_url | The URL is unknown, was replaced or is disabled. |
| 429 | rate\_limited | More than 600 events in the current minute. |

The inbound URL does not count towards your API key's request rate. To import an existing user base, use [Import](https://docs.cabalspy.xyz/cabalspy-id/platform-users-import.md) instead.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.