mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-09 00:03:10 +00:00
345 lines
16 KiB
Plaintext
345 lines
16 KiB
Plaintext
---
|
|
title: OAuth
|
|
description: Let third-party apps act on behalf of a Warmbly workspace with the authorization-code flow.
|
|
---
|
|
|
|
API keys are for your own scripts. When you build an app that other people connect their Warmbly workspace to, use OAuth instead: the user grants your app scoped access on a consent screen, and you receive tokens that act on their behalf. You never see their password or API key.
|
|
|
|
Warmbly implements the OAuth 2.1 authorization-code flow with PKCE. Confidential apps (registered in the dashboard) hold a client secret; public clients (native, mobile, and CLI apps, including MCP clients that self-register) use PKCE with no secret. The implicit and password grants are not supported.
|
|
|
|
## Register your app
|
|
|
|
In the dashboard, open **Settings -> OAuth apps** and register an application. You provide:
|
|
|
|
- a **name**, optional description, logo, and website (shown on the consent screen),
|
|
- one or more **redirect URIs** (where users are sent back after they approve), matched exactly and required to be HTTPS, except loopback URLs for native apps,
|
|
- the **scopes** your app may request,
|
|
- optionally, **allowed webhook domains** (see below).
|
|
|
|
You receive a **client ID** and a **client secret**, shown only once. Keep the secret server-side and use it to authenticate the token exchange.
|
|
|
|
## Public clients and dynamic registration
|
|
|
|
Native apps, CLI tools, and MCP clients cannot safely hold a secret. They connect as public clients: no client secret, and PKCE is mandatory rather than optional. There are two ways to get a public `client_id`.
|
|
|
|
Self-register at runtime with Dynamic Client Registration ([RFC 7591](https://www.rfc-editor.org/rfc/rfc7591)). This is the path MCP clients use, so a user connects with one command:
|
|
|
|
```bash
|
|
curl -X POST "https://api.warmbly.com/v1/oauth/register" \
|
|
-H "Content-Type: application/json" \
|
|
-d '{
|
|
"client_name": "My MCP client",
|
|
"redirect_uris": ["http://127.0.0.1:8080/callback"],
|
|
"token_endpoint_auth_method": "none",
|
|
"grant_types": ["authorization_code", "refresh_token"],
|
|
"response_types": ["code"]
|
|
}'
|
|
```
|
|
|
|
```json
|
|
{
|
|
"client_id": "wmcid_...",
|
|
"client_id_issued_at": 1752624000,
|
|
"redirect_uris": ["http://127.0.0.1:8080/callback"],
|
|
"token_endpoint_auth_method": "none",
|
|
"grant_types": ["authorization_code", "refresh_token"],
|
|
"response_types": ["code"],
|
|
"scope": "read_campaigns read_contacts ..."
|
|
}
|
|
```
|
|
|
|
The registration endpoint is open (unauthenticated) and rate-limited per source IP. Registering grants no access on its own: a human still signs in and approves scopes at the consent screen. Redirect URIs may be HTTPS, loopback (`http://127.0.0.1` or `localhost`, any port), or a private-use scheme (`myapp://`), matched exactly at authorize and token time. Self-registered clients can never request `SEND_CAMPAIGNS` or `API_KEYS`.
|
|
|
|
For a public client the token exchange sends no `client_secret`, only the PKCE `code_verifier`. Everything else in the flow below is identical. If you are connecting an AI assistant, you do not run any of this by hand: see the [MCP server](/api/mcp/) page for the one-command setup.
|
|
|
|
## Scopes
|
|
|
|
OAuth scopes are the same permissions as API keys, lowercased (for example `read_campaigns`, `write_contacts`). A token can only ever carry scopes that the app was registered with. See the [permission reference](/api/permissions/) for the full list. The issued access token authenticates API calls with exactly those permissions, through the same gates as an API key.
|
|
|
|
## The flow
|
|
|
|
### 1. Send the user to the authorize page
|
|
|
|
Redirect the user's browser to the consent page with the standard parameters. PKCE is optional but recommended: generate a `code_verifier` (a high-entropy random string) and send its S256 challenge.
|
|
|
|
```
|
|
code_challenge = base64url( sha256( code_verifier ) )
|
|
```
|
|
|
|
```
|
|
https://app.warmbly.com/oauth/authorize
|
|
?response_type=code
|
|
&client_id=wmcid_...
|
|
&redirect_uri=https://yourapp.com/oauth/callback
|
|
&scope=read_campaigns%20read_contacts
|
|
&state=<random-csrf-token>
|
|
&code_challenge=<challenge>
|
|
&code_challenge_method=S256
|
|
```
|
|
|
|
`state` is yours to verify on return (CSRF protection). The `code_challenge` and `code_challenge_method` are optional; if you include them, `code_challenge_method` must be `S256`.
|
|
|
|
### 2. The user approves
|
|
|
|
Warmbly shows the app, the scopes it wants, and an authorize or deny choice. On approval the browser is redirected back to your `redirect_uri` with a single-use code:
|
|
|
|
```
|
|
https://yourapp.com/oauth/callback?code=wmac_...&state=<your-state>
|
|
```
|
|
|
|
If the user denies, the redirect carries `?error=access_denied&state=...` instead.
|
|
|
|
### 3. Exchange the code for tokens
|
|
|
|
Verify `state` matches, then `POST` to the token endpoint. The request is `application/x-www-form-urlencoded`; send your `client_secret` (in the body or via HTTP Basic auth), plus the original `code_verifier` if you used PKCE. A public client omits `client_secret` and always sends `code_verifier`.
|
|
|
|
```bash
|
|
curl -X POST "https://api.warmbly.com/v1/oauth/token" \
|
|
-d grant_type=authorization_code \
|
|
-d code=wmac_... \
|
|
-d redirect_uri=https://yourapp.com/oauth/callback \
|
|
-d client_id=wmcid_... \
|
|
-d client_secret=wmcs_... \
|
|
-d code_verifier=<the-original-verifier>
|
|
```
|
|
|
|
```json
|
|
{
|
|
"access_token": "wmat_...",
|
|
"token_type": "Bearer",
|
|
"expires_in": 3600,
|
|
"refresh_token": "wmrt_...",
|
|
"scope": "read_campaigns read_contacts"
|
|
}
|
|
```
|
|
|
|
### 4. Call the API
|
|
|
|
Use the access token exactly like an API key:
|
|
|
|
```bash
|
|
curl "https://api.warmbly.com/v1/campaigns" \
|
|
-H "Authorization: Bearer wmat_..."
|
|
```
|
|
|
|
### 5. Refresh when it expires
|
|
|
|
Access tokens last one hour. Exchange the refresh token for a new pair before or after expiry. Refresh tokens **rotate**: the old one stops working the moment a new pair is issued, so always store the latest one.
|
|
|
|
```bash
|
|
curl -X POST "https://api.warmbly.com/v1/oauth/token" \
|
|
-d grant_type=refresh_token \
|
|
-d refresh_token=wmrt_... \
|
|
-d client_id=wmcid_... \
|
|
-d client_secret=wmcs_...
|
|
```
|
|
|
|
### Revoke
|
|
|
|
Revoke an access or refresh token (and the grant behind it) when the user disconnects:
|
|
|
|
```bash
|
|
curl -X POST "https://api.warmbly.com/v1/oauth/revoke" \
|
|
-d token=wmat_... \
|
|
-d client_id=wmcid_... \
|
|
-d client_secret=wmcs_...
|
|
```
|
|
|
|
Users can also revoke your app themselves under **Settings -> OAuth apps -> Authorized apps**, which invalidates every token issued to it for that workspace.
|
|
|
|
## Token lifetimes
|
|
|
|
| Token | Lifetime | Notes |
|
|
| --- | --- | --- |
|
|
| Authorization code | 10 minutes | Single use, PKCE-bound |
|
|
| Access token | 1 hour | Bearer, carries the granted scopes |
|
|
| Refresh token | 90 days | Rotates on every use |
|
|
|
|
## Discovery
|
|
|
|
Endpoint URLs and capabilities are published for automatic client configuration.
|
|
|
|
Authorization-server metadata ([RFC 8414](https://www.rfc-editor.org/rfc/rfc8414)):
|
|
|
|
```
|
|
GET https://api.warmbly.com/.well-known/oauth-authorization-server
|
|
```
|
|
|
|
It lists the authorization, token, revocation, and registration endpoints, the supported scopes, `code` as the only response type, `authorization_code` and `refresh_token` grants, `S256` as the only PKCE method, and `none` (public), `client_secret_basic`, and `client_secret_post` as the client authentication methods.
|
|
|
|
Protected-resource metadata for the MCP endpoint ([RFC 9728](https://www.rfc-editor.org/rfc/rfc9728)):
|
|
|
|
```
|
|
GET https://api.warmbly.com/.well-known/oauth-protected-resource
|
|
```
|
|
|
|
It names the resource and its authorization server, so an MCP client that gets a `401` with a `WWW-Authenticate` challenge from `/v1/mcp` can find where to authenticate. See the [MCP server](/api/mcp/) page.
|
|
|
|
## Webhook domains for OAuth apps
|
|
|
|
If your app registers webhook endpoints on behalf of its users, you must declare the domains those endpoints may use. Set `allowed_webhook_domains` on the app object (a string array) when creating or updating the app:
|
|
|
|
```json
|
|
{
|
|
"name": "My App",
|
|
"redirect_uris": ["https://yourapp.com/oauth/callback"],
|
|
"allowed_webhook_domains": [".acme.com", "hooks.partnersite.com"]
|
|
}
|
|
```
|
|
|
|
Any webhook URL your app registers must have a host within this list. An empty list means the app cannot register webhook endpoints at all.
|
|
|
|
**Matching rules:**
|
|
|
|
- A bare entry (`acme.com`) matches that exact host only.
|
|
- A leading-dot entry (`.acme.com`) matches the apex and any subdomain: `acme.com`, `hooks.acme.com`, `prod.hooks.acme.com`.
|
|
|
|
The list is enforced at endpoint registration time and re-checked at delivery time. See the [webhooks reference](/api/reference/webhooks/) for the full endpoint verification and delivery flow.
|
|
|
|
## Receiving events as an app (webhooks)
|
|
|
|
Instead of holding one WebSocket connection per installed workspace, your app can declare a single webhook configuration and let Warmbly deliver events to it automatically for every org that authorizes the app. This is the GitHub/Slack-app model: configure once on the app, receive from all installs.
|
|
|
|
### App webhook config
|
|
|
|
Set `webhook_url` and `webhook_events` when creating or updating your app via `POST /oauth/applications` or `PATCH /oauth/applications/:id`.
|
|
|
|
| Field | Type | Description |
|
|
| --- | --- | --- |
|
|
| `webhook_url` | string | The HTTPS URL events are delivered to. Its host must be within the app's `allowed_webhook_domains` (a 400 is returned otherwise). Set to empty to opt out of app-level webhook delivery. |
|
|
| `webhook_events` | string[] | The event names the app subscribes to. An empty or omitted array subscribes to all non-firehose events permitted by the org's granted scopes. |
|
|
|
|
The signing secret is server-generated and shared across the whole app: one secret, used to sign deliveries from every org install. Reveal or rotate it using the dedicated endpoints below.
|
|
|
|
```json
|
|
{
|
|
"name": "My App",
|
|
"redirect_uris": ["https://yourapp.com/oauth/callback"],
|
|
"allowed_webhook_domains": [".yourapp.com"],
|
|
"webhook_url": "https://hooks.yourapp.com/warmbly",
|
|
"webhook_events": ["campaign.reply_received", "meeting.booked", "contact.created"]
|
|
}
|
|
```
|
|
|
|
### Automatic, scope-gated delivery
|
|
|
|
When an org authorizes your app, Warmbly automatically creates a managed webhook endpoint for that org and starts delivering the subscribed events. When the org revokes the app, that endpoint is removed.
|
|
|
|
Events are gated by the intersection of what your app declared in `webhook_events` and what the org's granted scopes permit. An event family is only delivered if both the app holds the matching scope and the org granted it. The scope-to-event mapping:
|
|
|
|
| Events | Required scope |
|
|
| --- | --- |
|
|
| `inbox.*` | `READ_UNIBOX` |
|
|
| `campaign.*`, `deliverability.*`, `meeting.*` | `READ_CAMPAIGNS` |
|
|
| `email_account.*`, `warmup.*` | `READ_EMAILS` |
|
|
| `contact.*`, `bulk_operation.*` | `READ_CONTACTS` |
|
|
| `crm.*` | `READ_CRM` |
|
|
| `automation.*` | `INTEGRATIONS` |
|
|
| `team.*`, `role.*`, `settings.*`, `subscription.*` | `READ_AUDIT_LOGS` |
|
|
| `custom.event` | `REALTIME_SUBSCRIBE` |
|
|
|
|
Every delivery payload carries `organization_id` so you can tell which install the event came from. Delivery is signed, retried with the same backoff schedule as regular webhooks, and verifiable using the same `X-Warmbly-Signature` scheme. See [Webhooks: verifying signatures](/api/reference/webhooks/#verifying-signatures) for the verification algorithm.
|
|
|
|
### App webhook management endpoints
|
|
|
|
All four endpoints require **Scope** `API_KEYS` and **org permission** `manage_api_keys`.
|
|
|
|
#### Reveal the webhook secret
|
|
|
|
`GET /oauth/applications/{id}/webhook-secret`
|
|
|
|
Returns the current signing secret. This is the `whsec_`-prefixed value used to verify all deliveries for this app, across every org install.
|
|
|
|
```json
|
|
{ "webhook_secret": "whsec_9f8e7d6c5b4a39281706f5e4d3c2b1a09f8e7d6c5b4a39281706f5e4d3c2b1a0" }
|
|
```
|
|
|
|
#### Rotate the webhook secret
|
|
|
|
`POST /oauth/applications/{id}/webhook-secret/rotate`
|
|
|
|
Issues a new signing secret and returns it once. Update your verification logic before in-flight deliveries settle against the old secret.
|
|
|
|
```json
|
|
{ "webhook_secret": "whsec_1a2b3c4d5e6f70819a2b3c4d5e6f70819a2b3c4d5e6f70819a2b3c4d5e6f7081" }
|
|
```
|
|
|
|
#### List per-org endpoints
|
|
|
|
`GET /oauth/applications/{id}/webhook-endpoints`
|
|
|
|
Returns the managed per-org endpoints that Warmbly materialized for this app, with their current health.
|
|
|
|
```json
|
|
{
|
|
"endpoints": [
|
|
{
|
|
"id": "a3f1c2e4-5b6d-7e8f-9a0b-1c2d3e4f5a6b",
|
|
"organization_id": "11111111-2222-3333-4444-555555555555",
|
|
"url": "https://hooks.yourapp.com/warmbly",
|
|
"enabled": true,
|
|
"verified_at": "2026-06-14T10:00:00Z",
|
|
"consecutive_failures": 0,
|
|
"last_success_at": "2026-06-14T10:05:00Z",
|
|
"last_failure_at": null
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
#### Cross-org delivery log
|
|
|
|
`GET /oauth/applications/{id}/webhook-deliveries`
|
|
|
|
Returns the delivery log across all org installs, newest first. Every attempt is included with its response code, error, payload, and retry state.
|
|
|
|
| Parameter | In | Type | Description |
|
|
| --- | --- | --- | --- |
|
|
| `status` | query | string | Filter by status: `pending`, `in_flight`, `delivered`, `failed`, or `abandoned`. |
|
|
| `event_type` | query | string | Filter by event type, for example `campaign.reply_received`. |
|
|
| `limit` | query | integer | Max rows. Between 1 and 200, defaults to 50. Out-of-range values return `400`. |
|
|
| `cursor` | query | string | Opaque pagination cursor from `pagination.next_cursor`. |
|
|
|
|
Response uses the standard `data` + `pagination` envelope with opaque cursors. Each row includes `organization_id` so you can see which install each delivery belongs to.
|
|
|
|
### Webhooks vs the realtime gateway
|
|
|
|
The [realtime gateway](/api/realtime/) and app-level webhooks deliver the same event vocabulary. Choose based on your integration's needs:
|
|
|
|
| | Realtime gateway | App-level webhooks |
|
|
| --- | --- | --- |
|
|
| Transport | WebSocket (persistent connection) | HTTP POST (stateless delivery) |
|
|
| Best for | Live UI, presence, low-latency dashboards | Durable processing, pipelines, serverless receivers |
|
|
| Connection model | One connection per install | No persistent connection; Warmbly pushes to your URL |
|
|
| Delivery guarantees | Lossy on disconnect (resume/replay available) | Retried with exponential backoff, inspectable log |
|
|
| Scope gating | Per-connection intents | Per-app scope, enforced per org grant |
|
|
|
|
Both are equally valid depending on what you are building. A dashboard-like integration benefits from the WebSocket's low latency and presence features. A data-pipeline or serverless integration benefits from the webhook's durable, individually-inspectable HTTP deliveries without holding a connection open for every installed org.
|
|
|
|
## Security notes
|
|
|
|
- Redirect URIs are matched exactly, so register every callback you use.
|
|
- Always send and verify `state`.
|
|
- PKCE is required for public clients and recommended for confidential apps; when used, only `S256` is accepted.
|
|
- Keep your client secret server-side. Native, mobile, and CLI apps should register as public clients and use PKCE rather than embedding a secret.
|
|
- Dynamic registration is open and per-IP rate-limited; a self-registered client is public, cannot request `SEND_CAMPAIGNS` or `API_KEYS`, and grants no access until a human approves scopes at consent.
|
|
- Set `allowed_webhook_domains` to the smallest set of domains your app needs. An overly broad list widens the surface for SSRF if a user tricks your app into registering an unexpected URL.
|
|
- The app webhook secret is shared across all org installs. Rotate it if it is compromised, and update your receiver promptly: in-flight deliveries that were already signed continue to verify against the old secret until they settle.
|
|
|
|
## Related
|
|
|
|
<Cards>
|
|
<Card title="Permissions" href="/api/permissions/">
|
|
The scopes an OAuth app can request, shared with API keys.
|
|
</Card>
|
|
<Card title="Authentication" href="/api/authentication/">
|
|
Using bearer tokens (API keys and OAuth) on the API.
|
|
</Card>
|
|
<Card title="Webhooks" href="/api/reference/webhooks/">
|
|
Delivery mechanics, signature verification, and the full event catalog.
|
|
</Card>
|
|
<Card title="Realtime gateway" href="/api/realtime/">
|
|
WebSocket alternative for live, low-latency event streaming.
|
|
</Card>
|
|
</Cards>
|