Endpointr API
A self-hosted REST gateway. JWT Bearer auth on every call; per-customer encrypted vault for third-party credentials; auto-generated REST routes; first-class outbound + inbound webhooks; provider-failover for AI; stateless OAuth relay for user-scoped credentials.
One contract for everything you'd otherwise glue together yourself.
Quick start (60 seconds, zero to first 200)
1. Import this collection into Postman.
2. Edit the collection variables (right-click collection *Edit* *Variables*):
| Variable | Set to |
|---|---|
| baseUrl | https://api.endpointr.com (or http://localhost:8080 while developing) |
| api_key | Your customer's api_key (from scripts/create_customer.php or the admin UI) |
| token | Leave blank auto-filled by the test script on the first POST /v1/token |
3. Run POST /v1/token in the *Auth* folder. The test script captures data.token into {{token}} and every other request inherits Authorization: Bearer {{token}}.
4. Set up a service for example, OpenAI: PUT /v1/credentials/openai with body {"api_key":"sk-..."}. Done. Now POST /v1/ai/chat with {"prompt":"hi"} works.
That's the whole onboarding loop.
Postman variables (recommended)
The example bodies use {{double_brace}} placeholders for things only you can supply (OAuth tokens, account IDs, contact-book IDs). Define them once in your Postman environment and every example "just works." The most useful ones:
| Variable | Where it's used | How to get it |
|---|
claude_oauth_token | AI examples for provider: claude-cli | claude setup-token (Claude Max subscription) |
missive_api_key | Missive passthrough mode | Missive *Preferences API Create token* (missive_pat-…) |
missive_account / missive_organization / missive_team / missive_user / missive_contact_book / missive_conversation | Missive examples that reference those resources | Look them up via the relevant GET /v1/mail/missive-* endpoint |
google_oauth_token | Google Tasks (direct access_token mode) | OAuth flow (1h TTL); for testing, oauth playground |
google_refresh_token / google_client_id / google_client_secret | Google Tasks (refresh-triplet mode) | Same OAuth playground; pick Tasks API v1 → tasks scope |
meta_ad_account_id | Every /v1/marketing/* example with account_id | GET /v1/marketing/ad-accounts after creds are set pick one. Includes the act_ prefix. |
meta_page_id | Lead-form / page examples | GET /v1/marketing/pages the id of the Page you'll run lead-gen ads from. |
meta_pixel_id | Conversions API examples | GET /v1/marketing/pixels?account_id={{meta_ad_account_id}} |
meta_webhook_verify_token | Webhook subscription examples | Whatever you set in the META_WEBHOOK_VERIFY_TOKEN env var (openssl rand -hex 32). |
Anything you see as <placeholder> in a description (e.g. <conversation-id>) is shorthand for "fill this in" usually a server-side ID you obtain by listing first.
Auth
POST /v1/token issue (no auth; body {api_key})PATCH /v1/token rotate (revokes old jti, issues new)DELETE /v1/token revoke (logout)POST /v1/keys upload your PEM public key ( 2048 bits)GET /v1/keys fetch your stored public key
Tokens carry a jti checked against a revocation table on every request.
Credentials vault, passthrough, hybrid
Most third-party credentials live in the encrypted service_credentials vault set them once, never touch them again. Some integrations also let you supply credentials per-request so you can keep secrets entirely client-side.
Vault-only services
| Service | Keys |
|---|
openai | api_key, optional organization |
anthropic | api_key |
openrouter | api_key, optional referer, app_title |
claude-cli | oauth_token (from claude setup-token) |
openai-cli | api_key |
stripe | api_key, optional webhook_secret |
dinero | client_id, client_secret, organization_id |
economic | app_secret, agreement_grant |
mailchimp | api_key (dc suffix in key), optional list_id |
ipregistry | api_key |
clearhaus | api_key |
meta-ads | access_token, app_id, app_secret, optional business_id, optional api_version (default v24.0) see Meta Marketing API first-time setup for the full provisioning recipe |
Hybrid services (vault OR per-request)
| Service | Vault entry | Per-request override field |
|---|
| AI providers (CLI variants) | claude-cli.oauth_token / openai-cli.api_key | oauth_token / api_key in body |
missive | missive.api_key | api_key in body (write verbs) or query (read verbs) |
google (Tasks) | google.{client_id, client_secret, refresh_token} | oauth_token OR same triplet in body (write verbs) / query (read verbs) |
Vault-only (handlers TBD)
| Service | Vault entry |
|---|
microsoft-graph | {tenant_id, client_id, client_secret, refresh_token} slot for upcoming Outlook / Calendar / To Do handlers; admins can stage credentials today, no API endpoint consumes them yet. |
Google in vault mode. Store the refresh-triplet once; Endpointr exchanges it for a fresh access_token on every call. Per-request oauth_token is then optional useful when a client already has a fresh access_token in hand.
When to pick which mode
- Vault. Set-and-forget. Required for binding-driven automations (
EventDispatcher) and inbound-webhook signature verification those flows have no live caller to attach credentials to. - Per-request. Keeps secrets out of the server entirely. Good for browser/mobile clients with their own secret store, or environments where the credential rotates frequently.
- Override-on-vault. Both modes coexist for hybrid services the per-request value wins for that single call, the vault remains the fallback. The override field is stripped before the upstream call so it never leaks into Missive's payload, query strings, or webhook event records.
When you add credentials for an AI provider, that provider is auto-appended to the customer's provider priority list see below.
Handler URL derivation
Handlers auto-register at /v1/<subdir>/<kebab-class-name>. Example: class StripeCustomersHandler in handlers/payments/ /v1/payments/stripe-customers.
REST verbs map to handler methods:
| Method / path | Handler method |
|---|
GET /v1/<g>/<name> | getAll(customerId) |
GET /v1/<g>/<name>/{id} | get(customerId, id) |
GET /v1/<g>/<name>/?… | getByParam(customerId, query) |
POST /v1/<g>/<name> | create(customerId, data) |
PUT /v1/<g>/<name>/{id} | update(customerId, id, data) |
DELETE /v1/<g>/<name>/{id} | delete(customerId, id) |
Only the verbs the handler implements are reachable. Unimplemented verbs return HTTP 500 with a descriptive error.
Response envelope
Every response is wrapped:
{
"httpCode": 200,
"status": "HTTP/1.1 200 OK",
"timestamp": "2026-04-21T12:00:00+00:00",
"requestId": "9f3d8e1a2b…",
"version": "1.0.0",
"data": { /* handler return value */ }
}
X-Request-Id header mirrors requestId include it in bug reports.
Webhooks
Every handler operation fires a typed event (e.g. StripeCustomersHandler.create, MissiveDraftsHandler.create). Register a listener with POST /v1/webhooks; the response returns a one-time secret. Receivers verify HMAC-SHA256 against the JSON body via the X-Endpointr-Signature: sha256=… header.
Sensitive fields (api_key, oauth_token, password, secret, etc.) are auto-redacted from webhook payloads your hybrid-credential override never reaches subscribers.
AI handlers unified multi-provider
Five endpoints, five providers. Pick one per request via the provider field, or leave it off and let the customer's priority list decide.
| Provider | Auth | Chat | Vision | Image gen | Stream | Models |
|---|
openai | per-customer api_key | | | | | |
anthropic | per-customer api_key | | | | | |
openrouter | per-customer api_key | | | | | |
claude-cli | oauth_token (Claude Max sub) | | | | | |
openai-cli | server-side codex login (sub) | | | | | |
CLI providers ride one shared subscription on this server; API providers bill to the customer's own account.
### Endpoints
- POST /v1/ai/chat text
- POST /v1/ai/conversation text + server-side memory + per-customer Elasticsearch retrieval (narration / TTS tone)
- POST /v1/ai/vision image-in + text-out
- POST /v1/ai/image image generation (openai, openrouter)
- POST /v1/ai/stream SSE chat stream
- POST /v1/ai/upload upload an image, get a short-lived public URL for vision providers
- GET /v1/ai/models/?provider=… list available models
- POST /v1/chat/completions (alias: POST /v1/ai/completions) OpenAI-compatible, see below
model is always passed via the model field no hardcoded default outside provider fallbacks.
OpenAI-compatible endpoint (/v1/chat/completions)
Drop-in replacement for OpenAI's POST /v1/chat/completions. Built for n8n's *OpenAI* credential, LangChain's ChatOpenAI, the official openai SDK, and anything else that speaks the OpenAI wire format point them at {{baseUrl}}/v1 and your customer api_key works as the OpenAI key.
Difference from the rest of /v1/* | Why |
|---|
Auth: Authorization: Bearer <api_key> the raw customer api_key, not a JWT. | n8n's OpenAI credential has no refresh hook; flat keys is what OpenAI does too. |
No response envelope. Body is the raw OpenAI shape ({id, object, created, model, choices, usage}); errors are {error: {message, type, code}}. | LangChain / OpenAI SDK reads these fields directly. |
Registered at two paths /v1/chat/completions (drop-in) and /v1/ai/completions (namespace match). | Same handler; pick whichever lines up with your client. |
Model routing. The model string picks the upstream:
| Model prefix | Upstream | Notes |
|---|
anthropic/… or claude-* | Anthropic (vault anthropic.api_key) | Full translation: tools input_schema, tool_use tool_calls, system extraction, stop_reason mapping. |
openai/… or gpt-* / o1-* / o3-* / o4-* | OpenAI (vault openai.api_key) | Near-passthrough. |
| anything else | OpenRouter (vault openrouter.api_key) | Model string passed verbatim (google/gemini-2.5-flash, etc.). |
Missing vault creds for the resolved upstream 401 with a remediation hint.
Tool calling. Required for n8n's AI Agent node. Send OpenAI-shape tools + tool_choice. The response carries finish_reason: "tool_calls" with tool_calls[].function.arguments as a JSON-encoded string (do not parse server-side clients reassemble). Continue the loop with {role: "tool", tool_call_id, content}. Parallel tool calls supported.
Streaming. "stream": true text/event-stream with chat.completion.chunk frames and data: [DONE] sentinel. Anthropic streams are translated event-by-event; OpenAI / OpenRouter streams are near-passthrough.
Accepted but ignored (so n8n payloads don't trip a 400): logprobs, top_logprobs, n, seed, logit_bias, user, service_tier.
Setup recipe. One-time for the customer:
1. Generate an api_key (admin UI or scripts/create_customer.php).
2. PUT /v1/credentials/anthropic (and/or openai, openrouter) with the upstream provider's key.
3. In n8n: create an *OpenAI* credential API Key = customer api_key, Base URL = https://api.endpointr.com/v1.
That's the whole loop. The customer's api_key is now their single credential for every n8n LLM node, AI Agent included.
Default models (when you omit model)
| Provider | Chat / Vision / Stream | Image gen |
|---|
anthropic | claude-sonnet-4-6 | |
claude-cli | claude-sonnet-4-6 | |
openai | gpt-4o-mini | gpt-image-1 |
openai-cli | gpt-5 | |
openrouter | openai/gpt-4o-mini | google/gemini-2.5-flash-image-preview |
Provider priority & failover
provider is optional on every AI endpoint. When it's omitted, the resolver consults the customer's provider priority list an ordered slug array stored on customers.provider_priority and managed in the admin UI at /admin/customers/{id} *Provider priority*.
Resolution is capability-aware:
1. Walk the priority list top-to-bottom.
2. Skip any slug that lacks credentials in service_credentials.
3. Skip any slug whose capability map doesn't include the requested operation (so claude-cli is skipped for image/models, openai-cli is skipped for everything except chat, etc.).
4. The first survivor handles the request. If 2+ survive, they're wrapped in a failover chain.
Failover (chat/vision/image/models): if the chosen provider returns 5xx, 401, 403, or 429, the next survivor is tried. Other 4xx responses surface immediately they're the caller's fault and the next provider would also reject the same payload.
Failover (stream): none. Once SSE headers are out, the connection is committed. The first qualifying candidate handles the stream; mid-stream errors surface as SSE error frames, not retries.
Explicit provider always wins. Passing provider in the body bypasses the priority list and uses exactly that provider with no failover useful when you specifically want to route a request to OpenAI's gpt-image-1 or OpenRouter's google/gemini-3-pro-image-preview ("Nano Banana Pro").
Image generation model picker
| Provider | Model | Notes |
|---|
openai | gpt-image-1 | Default OpenAI image model |
openai | dall-e-3 | Older DALLE |
openrouter | google/gemini-2.5-flash-image-preview | "Nano Banana" fast & cheap |
openrouter | google/gemini-3-pro-image-preview | "Nano Banana Pro" higher quality |
OpenRouter image responses are normalized to {b64_json: "…"} so callers can swap providers without reshaping output.
Meta Marketing API first-time setup
Endpointr's /v1/marketing/* endpoints are a thin canonicalised facade over Meta's Graph API. To use them you need a Meta App + a long-lived access token + an app_secret none of which Endpointr can mint for you. This section is the end-to-end recipe.
Time budget: ~30 minutes for a brand-new Meta account; ~10 minutes if you already have a Business Portfolio.
Glossary (just enough to follow the steps)
| Term | What it is |
|---|
| Meta App | The "client" Endpointr identifies as when calling Graph. Has an app_id + app_secret. Created at developers.facebook.com/apps. |
| Business Portfolio (formerly Business Manager) | A container that owns ad accounts, Pages, pixels, and people. Created at business.facebook.com. The portfolio's id is the business_id credential. |
| System User | A non-human user inside a Business Portfolio that owns access tokens. Tokens minted by a System User never expire (vs. ~60 d for human-user tokens) and survive password resets. This is what production should use. |
| Access token | The bearer string Endpointr sends on every Graph call. Stored at service_credentials.meta-ads.access_token. |
| Verify token | The shared secret Meta echoes during webhook subscription handshakes. Set app-wide via the META_WEBHOOK_VERIFY_TOKEN env var. |
Step 1 Create the Meta App (5 minutes)
1. Go to developers.facebook.com/apps Create app.
2. Use case: pick *Other* Next.
3. App type: *Business* Next.
4. Name / contact email: anything descriptive. Attach a Business Portfolio if you already have one (otherwise create one inline).
5. After creation, on the App Dashboard Add products click Set up on:
- Marketing API (required)
- Webhooks (only if you want inbound events leadgen, account-status changes, etc.)
6. Open App Settings Basic and grab:
- App ID app_id in the credential
- App Secret (click *Show*) app_secret in the credential
The app starts in Development mode, which is fine Marketing API works in dev mode for any ad account the app owner / a System User has access to. You only need to flip to *Live* if you want third parties to log in via this app.
Step 2 Create a Business Portfolio + System User (10 minutes)
Skip if you already have a Business Portfolio with a System User that owns the ad accounts you'll be managing.
1. Go to business.facebook.com Create account. Fill in the legal name + your contact email.
2. Inside the new portfolio Settings () Business Settings.
3. Users System Users Add. Name it something like endpointr-api. Role: Admin.
4. With the System User selected Add Assets Apps tick the app you created in Step 1 grant Full control.
5. Add Assets again Ad Accounts tick every ad account you want Endpointr to manage grant Manage ad account.
6. Repeat for Pages (needed for lead-gen ads + Page-attached creatives) and Pixels (needed for Conversions API).
7. Copy the Business Portfolio ID from *Business Settings Business Info* that's the business_id credential (optional leaving it blank means Endpointr discovers ad accounts via /me/adaccounts instead of /{business_id}/owned_ad_accounts; the latter is recommended for tokens that own many accounts).
Step 3 Generate a long-lived System User access token (3 minutes)
Still in *Business Settings Users System Users*:
1. Click the System User you created Generate new token.
2. App: pick the app from Step 1.
3. Token expiration: *Never*.
4. Permissions (tick all of these missing scopes silently break specific endpoints):
| Scope | Used for |
|---|---|
| ads_management | All write operations on campaigns, ad sets, ads, creatives |
| ads_read | All read operations + insights |
| business_management | business_id-scoped account discovery, catalogs |
| leads_retrieval | /v1/marketing/leads |
| pages_show_list | /v1/marketing/pages |
| pages_read_engagement | Lead-gen webhook + Page-attached creatives |
| pages_manage_metadata | Subscribing the Page to webhooks |
| instagram_basic *(optional)* | Instagram ads via the connected IG business account |
5. Click Generate token. Copy it now Meta only shows it once. That's the access_token credential.
Step 4 Store the credentials in Endpointr (1 minute)
PUT {{baseUrl}}/v1/credentials/meta-ads
Authorization: Bearer {{token}}
Content-Type: application/json
{
"access_token": "EAA...the-long-string-from-step-3",
"app_id": "1234567890123456",
"app_secret": "abcdef0123456789abcdef0123456789",
"business_id": "9876543210987654",
"api_version": "v24.0"
}
api_version is optional defaults to whatever META_DEFAULT_API_VERSION (env) is set to (v24.0 out of the box). Pin per-customer when one partner needs an older version.
Step 5 Smoke-test (30 seconds)
GET {{baseUrl}}/v1/marketing/auth
Authorization: Bearer {{token}}
Expected response:
{
"data": {
"debug_token": {
"app_id": "1234567890123456",
"type": "SYSTEM_USER",
"expires_at": 0,
"is_valid": true,
"scopes": ["ads_management", "ads_read", "business_management", "..."]
},
"scopes": {
"granted": ["ads_management", "ads_read", "..."],
"declined": []
}
}
}
If expires_at is not 0, you didn't generate a System User token short-lived tokens will work for testing but die in ~60 days. If scopes.granted is missing one of the rows from Step 3, go back to the System User and add the missing permission.
Then list your accounts:
GET {{baseUrl}}/v1/marketing/ad-accounts
Pick one of the act_… ids from the response that's your {{meta_ad_account_id}} Postman variable from here on.
Step 6 (optional) wire up webhooks
If you want Meta to push events (leadgen submissions, ad-account status changes), you also need:
1. Set the META_WEBHOOK_VERIFY_TOKEN env var server-side. Generate with openssl rand -hex 32. This is one app-wide value Meta echoes it during the handshake at GET /v1/webhooks/inbound/meta-ads.
2. In the Meta App Dashboard Webhooks, add the same value as the *Verify Token* and https://api.endpointr.com/v1/webhooks/inbound/meta-ads as the *Callback URL*. Subscribe to the objects you care about (page, ad_account, etc.). Meta will hit the GET endpoint immediately to validate the token.
3. Subscribe to specific fields via Endpointr's wrapper (uses the same callback URL by default and persists the local mapping so inbound deliveries can be attributed to the right customer):
POST {{baseUrl}}/v1/marketing/webhook-subscriptions
Content-Type: application/json
{
"object": "page",
"object_id": "{{meta_page_id}}",
"fields": ["leadgen"]
}
4. Test the inbound endpoint from Meta App Dashboard Webhooks Test pick leadgen. A row will appear in the meta_ad_webhook_events table within a second, and a leadgen_fetch job will be queued for the meta marketing worker. The full lead body lands in meta_ad_leads; subscribed MetaAds.page.leadgen outbound webhooks fire too.
Common gotchas
(#100) Param account_id… not supported you forgot the act_ prefix on an account id. Endpointr auto-prefixes when you pass it as account_id in a body, but some Graph error paths surface the raw error before that normalisation. Always include act_ to be safe.(#10) You do not have permission to perform this action the System User isn't assigned the relevant asset (ad account / Page / pixel) with *Manage* role. Back to Step 2.5/2.6.(#190) Error validating access token: Session has expired short-lived token; regenerate as a System User token (Step 3, expiration *Never*).(#368) The action attempted has been deemed abusive usually Meta thinks the request is bot-generated. Make sure the System User token was generated against the same app you're calling from, and that appsecret_proof is on (it is by default in MetaGraphClient when app_secret is set in the vault).- Conversions API events don't show up in Events Manager they take ~20 minutes to appear in the test events tab unless you pass
test_event_code: 'TEST123…' (from Events Manager *Test Events*). Production events appear under the normal aggregations after that delay. - Video upload stuck at
processing forever Meta's transcoder can occasionally fail silently. Re-upload with a different container/codec (H.264 + AAC in MP4 is the safest). The worker stops polling after 30 attempts (~5 minutes) and marks the asset failed with the last status payload.
Stubs endpoints present but dependency-missing
| Endpoint | Install |
|---|
/v1/rendering/html2-pdf | composer require dompdf/dompdf |
/v1/rendering/phantom-js | deprecated; migrate to Playwright externally |
These throw a clear RuntimeException explaining what to install.
Rebuilding this collection
When you add a handler or change an AI provider's behaviour, re-run:
php v1/generate_postman.php
php scripts/generate_docs.php
The first rewrites
postman_collection.json (re-import into Postman or use *Update* on the existing collection). The second rewrites
public/documentation/index.html from the same JSON, so both stay in sync.