Channels
A channel integration is the merchant's connection to a delivery provider — Resend for email, Twilio for SMS, the Meta Graph API for Instagram and Facebook posts, and so on. Channels are credentials plus configuration; once a channel is active, marketing campaigns can dispatch through it.
There are two ways to connect:
- Static credentials — for providers that authenticate with API keys (Resend, SendGrid, WA Cloud, Twilio, TikTok). POST the credentials directly.
- OAuth flow — for providers that require browser hops (Meta, LinkedIn, Twitter, YouTube, Pinterest, Threads). Send the merchant's browser to
GET /channels/oauth/:provider/startand let the provider's callback complete it.
This page covers the static-credential surface. The OAuth surface lives at /api/v1/channels/oauth/*; consult the Portal → Channels walkthrough for the dashboard equivalent.
Endpoints
| Method | Path | Purpose |
|---|---|---|
GET |
/api/v1/channels |
List channels |
POST |
/api/v1/channels |
Connect a static-credential channel |
GET |
/api/v1/channels/:id |
Retrieve a channel |
PATCH |
/api/v1/channels/:id |
Update a channel |
DELETE |
/api/v1/channels/:id |
Revoke a channel |
POST |
/api/v1/channels/:id/test |
Send a test message |
GET |
/api/v1/channels/:id/dns-records |
Email DNS guidance |
All endpoints require the merchant role.
List channels
GET /api/v1/channels
Returns every channel integration in the workspace, newest first. Credentials are never included — the response is the public-safe view.
{
"data": [
{
"id": "clx3k9v0000...",
"provider": "email_resend",
"externalId": null,
"displayName": "Production transactional",
"status": "active",
"config": { "fromEmail": "hello@example.com", "fromName": "ExampleCo" },
"scopesGranted": [],
"lastSyncedAt": "2026-05-13T10:42:00.000Z",
"lastError": null,
"expiresAt": null,
"createdAt": "2026-05-01T10:42:00.000Z"
}
],
"error": null,
"meta": { "requestId": "...", "timestamp": "..." }
}
Connect a channel
POST /api/v1/channels
For static-credential providers only. The credentials are wrapped using the per-merchant channel encryption key (channel-crypto.ts) before they hit the database, and decrypted only at dispatch time inside the worker.
OAuth-only providers (meta_business, linkedin, twitter, youtube, pinterest, threads) return 400 USE_OAUTH from this endpoint — they require the OAuth flow.
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
provider |
enum | yes | See the provider list below. |
displayName |
string (1–120) | yes | Human label, e.g. "Production transactional". Shown in the dashboard and campaign picker. |
externalId |
string (≤120) | null | no | Provider-side identifier, e.g. Twilio's messagingServiceSid, WhatsApp's phoneNumberId. |
credentials |
object (string-to-string) | yes | Raw provider credentials. The exact keys depend on the provider — see Credentials per provider. |
config |
object | no | Provider-specific configuration (e.g., fromEmail, fromName for email). |
scopesGranted |
string[] | no | Recorded for audit; not used at runtime. |
Response — 201 Created
A trimmed view of the new channel — exactly seven fields: id, provider, displayName, status (always active on this path), externalId, config, createdAt. The remaining readable fields (scopesGranted, lastSyncedAt, lastError, expiresAt) are only returned by GET /api/v1/channels/:id, so follow up with a retrieve if you need them.
Errors
| Status | error.code |
When |
|---|---|---|
400 |
VALIDATION |
Shape wrong. |
400 |
USE_OAUTH |
Tried to POST credentials for an OAuth-only provider. |
Examples
await ripllo.channels.create({
provider: 'email_resend',
displayName: 'Production transactional',
credentials: { apiKey: process.env.RESEND_API_KEY },
config: { fromEmail: 'hello@example.com', fromName: 'ExampleCo' },
});
Providers
| Category | Providers |
|---|---|
email_resend, email_sendgrid, email_mailgun, email_postmark, email_ses |
|
| SMS | sms_twilio, sms_vonage |
| Messaging | whatsapp_cloud, whatsapp_twilio, telegram_bot, line_business, discord_webhook, slack_webhook |
| Push | push_onesignal, push_fcm |
| Social (OAuth only) | meta_business, linkedin, tiktok_business, twitter, youtube, pinterest, threads |
| Generic | webhook_generic |
tiktok_business straddles the two paths: it accepts static credentials (long-lived access token from the developer portal) but most merchants reach it via OAuth.
Credentials per provider
The exact keys to put in credentials:
| Provider | Keys |
|---|---|
email_resend |
apiKey |
email_sendgrid |
apiKey |
email_mailgun |
apiKey, domain |
email_postmark |
serverToken |
email_ses |
accessKeyId, secretAccessKey, region |
sms_twilio |
accountSid, authToken |
sms_vonage |
apiKey, apiSecret |
whatsapp_cloud |
accessToken, phoneNumberId (the latter usually also as externalId) |
whatsapp_twilio |
accountSid, authToken, fromNumber (E.164 with whatsapp: prefix, e.g. whatsapp:+14155238886) |
telegram_bot |
botToken |
line_business |
channelAccessToken, channelSecret |
discord_webhook |
webhookUrl |
slack_webhook |
webhookUrl |
push_onesignal |
appId, apiKey |
push_fcm |
serverKey (FCM legacy) or serviceAccountJson (FCM v1) |
webhook_generic |
url, secret (optional, used to sign outbound) |
The schema accepts any string-keyed string-valued object — if you pass extra keys, they're stored and ignored. The runtime worker only reads what it needs.
Config per provider
config is opaque to Ripllo's persistence layer; per-provider workers read what they need. Common keys:
- Email:
fromEmail,fromName,replyTo. - WhatsApp Cloud:
templateNamespace(Meta's template namespace UUID). - WhatsApp Twilio: send-time
content.contentSid+content.contentVariablesmap to a pre-approved Twilio Content template; passcontent.textinstead for freeform replies inside the 24h customer window. Falls back toconfig.fromNumberif not stored incredentials. - Push (OneSignal):
appNamefor routing.
Retrieve a channel
GET /api/v1/channels/:id
Returns one channel by ID, credentials omitted.
Update a channel
PATCH /api/v1/channels/:id
Partial. Mutable fields: displayName and config only. Any other key — status, scopesGranted, credentials, provider — is stripped by the schema, so the request returns 200 having changed nothing. There is no API path to set status, which means a revoked or expired channel cannot be reactivated through this endpoint: reconnect it instead (POST /api/v1/channels, or the OAuth start for OAuth providers).
Returns { "id": "…" }, not the updated object.
To rotate credentials, mint a new channel and revoke the old one — in-place credential edits are not supported because they'd leave the old encrypted blob recoverable via DB snapshots.
Revoke a channel
DELETE /api/v1/channels/:id
Soft-revoke, and status-only. The handler writes exactly one field: status: "revoked". Any subsequent campaign send that references this channel returns 400 NO_CHANNEL, and POST /:id/test returns 409 BAD_STATE. Returns { "id": "…", "status": "revoked" }.
Revoking does not delete the credentials. The encrypted
credentialsblob stays on the row indefinitely — the row is kept for historical reference and nothing scrubs it. If a merchant disconnects because a provider key leaked, tell them to rotate or delete that key at the provider; revoking in Ripllo only stops Ripllo from using it.
Send a test message
POST /api/v1/channels/:id/test
The primary way to verify credentials actually work. Bypasses contacts, audiences and campaigns: it queues one real MarketingMessage to the recipient you name, dispatched by the worker through the same adapter a campaign send would use. Bad credentials show up as a failed message row with a meaningful error, not as an error on this call.
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
recipient |
string | yes | Where to send: an email address, an E.164 phone number, a chat id — whatever the provider addresses. |
The message body is generated for you, shaped per provider family (subject/html/text for email, text for SMS and chat, title/body for push).
Response — 200 OK
{
"data": {
"messageId": "clx3k9v0000...",
"queued": true,
"hint": "check the message in /audit-log or /webhooks for delivery status within ~10s"
},
"error": null,
"meta": { "requestId": "...", "timestamp": "..." }
}
This is asynchronous — queued: true means accepted for dispatch, not delivered. Poll the message or watch the audit log for the outcome.
Errors
| Status | error.code |
When |
|---|---|---|
400 |
VALIDATION |
recipient missing or blank. |
404 |
NOT_FOUND |
No such channel in this workspace. |
409 |
BAD_STATE |
Channel status is not active (message names the actual status). |
Email DNS guidance
GET /api/v1/channels/:id/dns-records
Email channels only. Returns the SPF / DKIM / DMARC records the merchant should publish on the domain of their config.fromEmail, so mail doesn't land in spam.
The records are static, provider-specific guidance — Ripllo does not resolve DNS and does not check whether you've published them. Values that only the provider can mint (DKIM selectors and keys) come back as placeholders with a providerDashboard deep link.
Response — 200 OK
{
"data": {
"provider": "email_resend",
"domain": "example.com",
"records": [
{ "kind": "SPF", "name": "example.com", "value": "v=spf1 include:amazonses.com ~all", "hint": "Single TXT record at the domain root..." },
{ "kind": "DKIM", "name": "resend._domainkey.example.com", "value": "<copy from Resend dashboard>", "hint": "Resend mints the public key on first verify.", "providerDashboard": "https://resend.com/domains" },
{ "kind": "DMARC", "name": "_dmarc.example.com", "value": "v=DMARC1; p=quarantine; ...", "hint": "Ramp `p` from `none` → `quarantine` → `reject`..." }
]
},
"error": null,
"meta": { "requestId": "...", "timestamp": "..." }
}
kind is one of SPF, DKIM, DMARC, CNAME.
Errors
| Status | error.code |
When |
|---|---|---|
404 |
NOT_FOUND |
No such channel in this workspace. |
409 |
NOT_EMAIL |
Channel provider isn't an email_* one. |
409 |
NO_FROM_EMAIL |
config.fromEmail isn't set, so there's no domain to build records for. |
The channel integration object
| Field | Type | Nullable | Notes |
|---|---|---|---|
id |
string | no | Opaque cuid (clx3k9v0000…), no prefix. |
accountId |
string | no | Owning workspace. Stored, but not included in API responses. |
provider |
enum | no | See Providers. |
externalId |
string | yes | Provider-side identifier. OAuth-created channels carry the literal "pending" until asset selection lands. |
displayName |
string | no | Human label. |
status |
enum | no | pending (column default), active, expired, revoked. There is no errored status — don't branch on one. |
config |
object | no | Free-form per-provider config. |
credentials |
(encrypted) | no | Never returned over the wire. |
scopesGranted |
string[] | no | Audit-only. Filled from the OAuth token response on that path. |
lastSyncedAt |
ISO 8601 | yes | Stamped at connect time only (static create, or OAuth callback). Nothing updates it per dispatch. |
lastError |
string | yes | Currently always null — no code writes it. Per-send failures live on the MarketingMessage row instead, which is what the test endpoint's hint points you at. |
expiresAt |
ISO 8601 | yes | Access-token expiry, from the OAuth token response. |
createdAt |
ISO 8601 | no | |
updatedAt |
ISO 8601 | no | Stored, but not included in API responses. |
OAuth flow (overview)
For OAuth providers, the flow is a pair of browser GETs, not JSON POSTs. The redirect URI is derived server-side from RIPLLO_OAUTH_REDIRECT_BASE — you never supply one.
- Send the merchant's browser to
GET /api/v1/channels/oauth/:provider/start(session-authenticated,merchantrole). It doesn't return a URL — it responds302straight to the provider's authorize page, withstatecarrying the workspace id. - The merchant authenticates at the provider and is redirected to
GET /api/v1/channels/oauth/:provider/callback?code=…&state=…. - Ripllo exchanges the code for tokens, encrypts them, upserts the
ChannelIntegrationwithstatus: "active"andexternalId: "pending", then302s the merchant to/dashboard/channels?connected=<provider>.
Start-endpoint errors: 404 UNKNOWN_PROVIDER for a provider that isn't wired, 503 OAUTH_NOT_CONFIGURED when the provider's client-id env var is unset. The callback replies in plain text (not the JSON envelope) on failure.
There is no token refresh. The
refreshTokenis stored at callback time and never read again — no job scansexpiresAt, and nothing flips a channel toexpired. When a provider's access token lapses, sends start failing at the adapter and the merchant has to re-run the OAuth start. Refresh is roadmap, not shipped.
Full OAuth-flow documentation is on the roadmap; the underlying routes are documented inline in channel-oauth.ts.
Events
| Event type | Fires on | Status |
|---|---|---|
ripllo.channel.connected.v1 |
New channel created (static or OAuth callback). | Reserved — not currently emitted. |
ripllo.channel.errored.v1 |
Intended for a channel going unusable after repeated failures. | Reserved — nothing emits it, and no code path marks a channel unusable today. |
ripllo.channel.revoked.v1 |
Channel deleted. | Reserved. |
Next
- Marketing campaigns — what you do with a connected channel.
- Contacts — how recipient identifiers map to channels at dispatch time.