# Authentication

Source: https://modulify.ai/docs/api/authentication

How a request proves who it is, what a token's scopes allow, and which workspaces it can reach.

Every request to the API, except `GET /v1/openapi.json`, carries an access token. The API uses the same tokens as the [MCP server](https://modulify.ai/docs/mcp): a token you created for an AI client works here too, and the other way round.

## Send the token

Put the token in the `Authorization` header, after the word `Bearer`:

```bash
curl -X POST https://api.modulify.ai/v1/list_workspaces \
  -H "Authorization: Bearer YOUR_TOKEN"
```

The header is also accepted with the bare token and no `Bearer` in front of it.

> **Warning**
>
> Never put a token in a URL. A request whose address contains one is refused with a `400` reading "Send the access token in the Authorization header, never in the URL. Revoke this token and create a new one, since URLs end up in logs!" Addresses are written to logs along the way, so treat that token as leaked and replace it.

## Create a token

Tokens live on the **Tokens** page, reached from the user menu in the dashboard at `/dashboard/tokens`. **Create token** asks for:

| Field | Rule |
| --- | --- |
| Token name | Required, up to 60 characters. Name it after the script or service that will use it |
| Expires after | 30, 90 or 180 days, or 1 year. Defaults to 90 days and never changes afterwards |
| Which workspaces it can reach | Every workspace you are a member of, or only the ones you tick. Shown when you belong to more than one |
| What this token can do | The scopes. At least one must be ticked |

The value is shown once and starts with `mdf_mcp_`. Modulify keeps only a hash of it, so a lost token cannot be read back: revoke it and create another. You can hold 10 active tokens at a time.

On the same page, open **Connecting a client** and choose the **HTTP API** tab for a ready-made `curl` call with the right address.

## Check a token

`GET /v1/token` answers with what a token is, without running any method:

```bash
curl https://api.modulify.ai/v1/token \
  -H "Authorization: Bearer YOUR_TOKEN"
```

```json
{
  "success": true,
  "message": "...",
  "data": {
    "id": "6710f0c2a1b2c3d4e5f60718",
    "name": "Nightly content sync",
    "scopes": ["workspaces:read", "sites:read", "chat:read", "chat:write"],
    "workspaces": [],
    "expiresAt": "2027-01-06T09:30:00.000Z",
    "rateLimit": { "limit": 240, "remaining": 238, "resetSeconds": 41 }
  },
  "code": 200,
  "version": "0.0.1740"
}
```

An empty `workspaces` list means every workspace you belong to. It needs no scope and does not count against the token's per-minute budget, so a script can call it on start to fail early with a clear reason.

## Scopes

A scope unlocks a set of methods. Each method's page names the one it needs, and `GET /v1/tools` lists only the methods the token's scopes allow. Calling a method outside them is refused with a `403` that names the missing scope.

The scopes are the same ones the MCP server uses, with the same defaults. [Tokens and scopes](https://modulify.ai/docs/mcp/tokens-and-scopes) explains what each one permits and why ten of them are off by default. The table below is built from the method pages:

| Scope | Tools |
| --- | --- |
| `account:read` | `get_account`, `list_pending_invitations`, `list_referrals`, `list_payouts`, `check_affiliate_code` |
| `account:write` | `update_account`, `update_affiliate` |
| `analytics:read` | `get_site_analytics`, `get_site_analytics_overview`, `get_analytics_status`, `get_site_realtime_visitors`, `check_analytics_installation`, `query_site_analytics`, `export_site_analytics` |
| `chat:read` | `get_job`, `list_messages`, `list_queued_messages`, `list_code_backups` |
| `chat:write` | `send_message`, `cancel_job`, `edit_queued_message`, `reorder_queued_message`, `resume_queue` |
| `code:read` | `list_files`, `read_file` |
| `code:write` | `write_file` |
| `comments:read` | `list_comments`, `get_comment`, `count_open_comments`, `list_comment_attachments` |
| `comments:write` | `create_comment`, `reply_to_comment`, `update_comment`, `resolve_comment`, `reopen_comment`, `delete_comment`, `move_comment_pin`, `add_comment_reaction`, `remove_comment_reaction`, `upload_comment_attachment`, `delete_comment_attachment` |
| `config:read` | `get_domain_status`, `get_domain_connect_url`, `list_secrets`, `list_site_skills`, `list_site_connectors`, `list_site_crons`, `list_site_cron_runs`, `preview_cron_schedule`, `export_site_crons`, `list_site_webhooks`, `list_webhook_deliveries`, `get_webhook_delivery_stats`, `export_site_webhooks`, `list_deploy_hooks`, `list_deploy_hook_calls`, `get_deploy_hook_call_stats`, `export_deploy_hooks`, `get_site_email_settings`, `get_site_email_usage`, `list_site_email_sends`, `get_site_email`, `get_site_email_stats`, `list_site_email_suppressions`, `list_site_email_engagement`, `list_site_email_credits`, `get_site_email_credit_stats` |
| `config:write` | `add_custom_domain`, `remove_custom_domain`, `add_www_domain`, `remove_www_domain`, `start_domain_verification`, `verify_domain`, `cancel_domain_verification`, `set_secret`, `set_secrets`, `delete_secret`, `rename_secret`, `set_site_skill`, `set_site_connector`, `toggle_site_cron`, `run_site_cron`, `create_site_cron`, `update_site_cron`, `delete_site_cron`, `delete_all_site_crons`, `delete_site_cron_run`, `clear_site_cron_runs`, `toggle_site_webhook`, `test_site_webhook`, `create_site_webhook`, `update_site_webhook`, `delete_site_webhook`, `delete_all_site_webhooks`, `delete_webhook_delivery`, `clear_webhook_deliveries`, `toggle_deploy_hook`, `create_deploy_hook`, `rename_deploy_hook`, `delete_deploy_hook`, `delete_all_deploy_hooks`, `delete_deploy_hook_call`, `clear_deploy_hook_calls`, `toggle_site_emails`, `update_site_email_settings`, `send_site_email`, `send_test_site_email`, `block_site_email_address`, `delete_site_email_suppression`, `add_site_email_domain`, `check_site_email_domain`, `remove_site_email_domain`, `set_site_email_sending_domain`, `set_site_email_domain_receiving`, `restart_site_email_domain_verification`, `get_site_email_domain_connect_url`, `set_site_email_forwarding`, `add_site_email_forward_address`, `resend_site_email_forward_address`, `remove_site_email_forward_address`, `delete_site_email_send`, `clear_site_email_history`, `rotate_site_email_key` |
| `credentials:reveal` | `get_secret`, `get_all_secrets`, `get_site_webhook_secret`, `rotate_site_webhook_secret`, `get_deploy_hook_url`, `rotate_deploy_hook_url`, `get_site_email_key`, `get_storage_key`, `rotate_storage_key`, `get_analytics_key`, `rotate_analytics_key` |
| `data:read` | `get_site_database`, `list_collections`, `get_collection_schema`, `list_rows`, `list_reference_options`, `list_enum_options`, `export_collection`, `list_storage_files`, `get_storage_stats`, `get_storage_info`, `get_storage_file_url`, `read_storage_file`, `browse_storage_backup`, `read_storage_backup_file`, `get_storage_restore_status` |
| `data:sql` | `execute_sql` |
| `data:write` | `create_row`, `update_row`, `delete_row`, `create_site_database`, `upload_storage_file`, `create_storage_folder`, `move_storage_files`, `delete_storage_files`, `delete_storage_folder`, `clear_storage`, `set_storage_visibility`, `set_storage_root_visibility`, `restore_storage_backup` |
| `logs:read` | `get_site_logs`, `get_publish_logs` |
| `members:write` | `invite_workspace_member`, `resend_workspace_invite`, `cancel_workspace_invite`, `remove_workspace_member`, `assign_member_role` |
| `publish:write` | `publish_site`, `unpublish_site`, `stop_publish`, `trigger_deploy_hook` |
| `sites:create` | `create_site`, `duplicate_site` |
| `sites:delete` | `delete_site`, `delete_folder`, `move_folder_to_workspace`, `remove_queued_message`, `clear_site_chat`, `clear_collection`, `clear_site_database`, `delete_backup`, `restore_code_backup`, `delete_storage_backup`, `clear_storage_backups`, `delete_database_backup`, `clear_database_backups`, `clear_site_analytics` |
| `sites:read` | `list_sites`, `get_site`, `check_subdomain_available`, `list_templates`, `get_ai_preferences`, `list_folders`, `check_folder_slug_available`, `get_publish_status`, `list_deployments`, `get_publish_timeline`, `list_backups`, `list_storage_backups`, `list_database_backups` |
| `sites:write` | `rename_site`, `archive_sites`, `unarchive_sites`, `set_show_badge`, `update_ai_preferences`, `change_subdomain`, `update_sharing`, `create_folder`, `rename_folder`, `update_folder_slug`, `move_sites_to_folder`, `remove_sites_from_folder`, `reorder_folders`, `create_backup`, `restore_backup`, `create_storage_backup`, `set_storage_backup_schedule`, `create_database_backup`, `set_database_backup_schedule`, `set_analytics_enabled` |
| `workspaces:read` | `list_workspaces`, `get_workspace`, `list_workspace_members`, `check_member_email`, `get_workspace_credits`, `get_workspace_credit_usage`, `list_workspace_credit_activity`, `get_role_member_counts`, `get_site_usage` |
| `workspaces:write` | `create_workspace`, `rename_workspace`, `set_default_workspace`, `leave_workspace`, `create_workspace_role`, `update_workspace_role`, `delete_workspace_role` |

Grant a token only what its job needs. A script that reads analytics every night needs `analytics:read` and `sites:read`, not `sites:delete`.

## Workspaces

A token reaches every workspace you are a member of unless you narrowed it to some of them. Every method that works inside a workspace says which workspace or site it acts on, and three checks run before it does:

1. The call must point at a workspace, through its `workspaceId` or the workspace that owns its `projectId`. A call that points at neither, or at something that does not exist, is refused.
2. The token must be allowed to reach that workspace.
3. Your role in that workspace must hold the **API access** permission, described in the product as "Reach this workspace with a token from an AI client". Owners hold every permission. See [Members and roles](https://modulify.ai/docs/reference/members-and-roles#every-permission).

A refusal from any of them is a `403`. A few methods belong to no workspace, such as `list_workspaces`, `create_workspace` and the account methods, which act only on the account the token belongs to.

## When a token stops working

| Answer | What it means |
| --- | --- |
| `401` "An access token is required. Send it in the Authorization header as Bearer followed by the token!" | No token arrived. Check the header name and that the value is not empty |
| `401` "This access token is invalid, revoked or expired!" | The value does not match a live token. It was cut short, revoked, or it passed its expiry date |

Both answers carry a `WWW-Authenticate: Bearer realm="modulify"` header, and the second adds `error="invalid_token"`. Revoking a token from the Tokens page takes effect at once, for the API and for MCP alike. An expired token cannot be extended, so create a new one.

## Next

- [Security](https://modulify.ai/docs/api/security) for keeping tokens safe.
- [Testing](https://modulify.ai/docs/api/testing) for trying calls without risk.