Inbox Apps
Inbox Apps Guide
Inbox Apps let partners provide a new messaging channel inside Sure Send CRM's shared Team Inbox. A team installs your app, a dedicated inbox appears in their chooser, and messages flow both ways over HMAC-signed webhooks.
Think of Inbox Apps as the inverse of Widgets: widgets render inside Sure Send as iframes, while Inbox Apps live outside Sure Send and integrate via webhooks and a REST API.
Overview
A complete Inbox App installation has four moving pieces:
- Partner registration — You register your app with Sure Send, receive a
secret_key(signing key) and aslug(URL-routing element for inbound webhooks). Optionally register aredirect_urito enable the OAuth-style token handoff. - Team install (consent flow) — A team owner/admin reaches the consent screen either by clicking Install in Sure Send's Marketplace UI or by following a signed install link you publish from your own site. After consent, Sure Send mints a scoped
ApiTokenand either reveals it in-line or hands it off via a code-exchange redirect. - Inbound webhooks — Your platform POSTs to
https://<suresend>/api/inbox_apps/<slug>/webhookwhenever something happens on your side (a message arrives, a conversation is created). Sure Send verifies the HMAC signature and materializes anInboxConversationinside the team's inbox. - Outbound webhooks — When a team member replies inside Sure Send (or closes/assigns/reopens a conversation, or installs/uninstalls your app), Sure Send POSTs the event back to your
webhook_ingress_url, signed with the same secret.
Terminology
- App — Your registration record on Sure Send. Owns the signing secret, slug, webhook URL, requested scopes, and declared capabilities.
- Install — A team's activation of your app. Creates a per-team
Inbox{kind: inbox_app}and anInboxAppAuthorizationrecord. - Capability — A boolean feature flag you declare on the app (e.g.
close,reopen,attachments). Gates outbound webhook events and composer affordances.
Registration
Register an app via the partner-team-facing page at /integrations/inbox-apps. You'll provide:
- Name (unique)
- Description
- Webhook ingress URL — where Sure Send posts outbound events
- Redirect URL (optional) — where Sure Send sends the user after consent, with a one-time install code in the query string. When set, the bearer token is never rendered to the user's browser — your backend exchanges the code for the token instead. See Install flow (consent). Must be
https://(orhttp://localhostfor development). - Requested scopes — what your app can read/write via the REST API
- Outbound events — which lifecycle events Sure Send should send you
- Capabilities — what your app supports (used by the composer UI and the outbound webhook gate)
On create, the signing secret is revealed once. Copy it — you will not see it again. If you lose it, use Rotate secret to mint a new one (this invalidates any previous secret; existing deployments must be updated).
Approval
Apps start unapproved. An unapproved app is only visible to the owning team (useful for end-to-end testing against your own team). Once you're ready, click Request approval — a Sure Send admin will review and flip the approval flag, at which point any team with your install link can install.
Scopes
| Scope | What it grants |
|---|---|
read_people | Read person/contact records (via the standard partner API) |
write_people | Create/update people |
read_inbox_conversations | List and show conversations in your installed inbox |
write_inbox_conversations | Close/reopen/assign conversations |
read_inbox_messages | Read messages on conversations in your inbox |
write_inbox_messages | Post outbound messages into conversations |
read_notes / write_notes | Read and post notes on conversations |
Capabilities
| Capability | Effect |
|---|---|
attachments | Composer shows the attach button; outbound events include attachments |
read_receipts | Composer surfaces read receipts UI |
close | Sure Send fires conversation.closed webhooks to your endpoint |
reopen | Sure Send fires conversation.reopened webhooks |
channel_label | Your app can override the channel label in the conversation header |
typing_indicators | Composer shows "partner is typing..." indicators |
A capability set to false (or missing) disables the corresponding behaviour. message.sent, conversation.assigned, install.completed, and install.revoked are always fired if listed in outbound_events — they are not gated by capabilities.
Install flow (consent)
Every install — whether the user clicks Install on Sure Send's Marketplace or follows an install link you publish from your own site — goes through the same consent flow. There is no one-click bypass; the partner always learns about the install via this handshake.
Two entry points lead to the same place:
- From Sure Send's UI: a team owner/admin clicks Install on your app's Marketplace card. Sure Send's frontend hits
GET /api/integration_partner_inbox_apps/:id/begin_install, which returns a signed initiate URL with a 1-hour TTL, then navigates the browser to it. There is nopartner_statein this flow — the install wasn't initiated from your site, so there's no session of yours to bind to. Yourredirect_uriwill receive?code=...with nostate=. - From your site: you generate the same kind of signed link from your backend and link to it. You should include a
partner_stateso you can verify the install completed in the same browser session that started it.
Both land in the same place — the initiate endpoint:
https://<suresend>/api/inbox_app_authorizations/initiate
?inbox_app_id=<your_app_uuid>
&expires_at=<unix_timestamp>
&sig=<hmac_signature>
&partner_state=<opaque_csrf_token> # optional, recommended
The signature is HMAC_SHA256(SURESEND_SECRET_KEY_BASE, signed_string) — contact Sure Send for the shared signing key. signed_string depends on whether you include partner_state:
| Form | signed_string |
|---|---|
Without partner_state | "<inbox_app_id>:<expires_at>" |
With partner_state | "<inbox_app_id>:<expires_at>:<partner_state>" |
expires_at is enforced server-side; expired links redirect to an error page. partner_state is opaque to Sure Send — generate a per-session CSRF token, store it on your side, and verify on the way back. Binding it into the signature prevents a man-in-the-middle from substituting their own state value while leaving the rest of the URL intact.
The user is bounced through login if needed, then lands on /inbox-app-authorize?state=<token>. The state token expires 5 minutes after the consent page is generated. They approve or deny. On approve, Sure Send:
- Calls
install_for_team!— creates a dedicatedInbox{kind: inbox_app}for the team and adds the consenting user as an inbox member. - Mints an
ApiTokenwith the scopes you requested. Token scope isteamwhen your app hasrequires_team_authorization: true(the default; only team owners/admins can approve), oruserwhen set tofalse(any member can approve; token is bound to that user). - Hands the token to you — how depends on whether your app has a registered
redirect_uri:
Without redirect_uri → user-clipboard handoff
redirect_uri → user-clipboard handoffSure Send renders the bearer token in the response body of the approve POST and tells the user to copy it. The user pastes it into your app somewhere. This is the simplest path, but it has obvious downsides: the token lives in the user's clipboard and browser history, and your install UX has a "paste the token here" step. Suitable for early dev or for apps that don't yet have a backend.
With redirect_uri → OAuth-style code exchange (recommended)
redirect_uri → OAuth-style code exchange (recommended)Sure Send issues a one-time install code (5-minute TTL, single-use) and 302-redirects the browser to:
<your_redirect_uri>?code=<one_time_code>&state=<partner_state>
state=... is omitted entirely when the install came from Sure Send's Marketplace UI (since there's no partner_state to echo). Your callback handler must accept both shapes — with and without state.
Your backend exchanges the code for the bearer token at:
POST https://<suresend>/api/inbox_app_authorizations/exchange
Content-Type: application/json
{
"slug": "<your_app_slug>",
"secret_key": "<your_signing_secret>",
"code": "<one_time_code>"
}
Success response:
{
"api_token": "<bearer_token>",
"display_token": "<short_display_form>",
"scopes": ["read_inbox_messages", "write_inbox_messages", "..."],
"team_id": "<team_uuid>",
"authorization_id": "<auth_uuid>"
}Failure response is a uniform 401 Unauthorized for any failure mode (unknown slug, wrong secret_key, unknown / expired / already-used code) so that codes and slugs cannot be enumerated.
Security notes for the redirect flow:
- The code is single-use. A second exchange attempt with the same code returns
401. - The code expires 5 minutes after being issued.
- When
stateis present on the redirect, verify it matches thepartner_stateyou generated for the user's session before calling/exchange. Whenstateis absent (Marketplace-initiated install), treat the install as legitimate but unassociated with any of your sessions — typically you'll show the user a "your app was just installed by team X" landing page rather than tying it to a logged-in session. - The token never touches the user's browser. It moves server-to-server.
- Your
redirect_urimust behttps://(orhttp://localhostfor development).
Inbound webhooks
Endpoint: POST https://<suresend>/api/inbox_apps/<slug>/webhook
Unauthenticated — authenticity is established via the X-SureSend-Signature header. Include these headers on every request:
| Header | Value |
|---|---|
Content-Type | application/json |
X-SureSend-Signature | sha256=<hex_hmac> where hex_hmac = HMAC_SHA256(app.secret_key, "<X-SureSend-Timestamp>.<raw_body>") |
X-SureSend-Timestamp | Unix epoch seconds; must be within ±5 minutes of now (replay guard). Included in the HMAC so an attacker can't swap it without invalidating the signature. |
Any auth failure — missing header, unknown slug, bad signature, or stale timestamp — returns a uniform 401 Unauthorized. (Sure Send deliberately does not distinguish unknown slug from a bad signature so that callers cannot enumerate valid slugs by observing 404 vs 401.) Malformed JSON returns 400. A team that exists but has not installed the app returns 422. Rate limit is 120 req/min per slug + source IP; excess returns 429.
Every payload must include a top-level team_id (UUID of the team that installed the app). It is used to look up the installed inbox; a payload missing team_id is 400.
Event types
message.received — a new inbound message on your platform
{
"event": "message.received",
"team_id": "<team_uuid>",
"message": {
"external_message_id": "your-unique-id",
"external_conversation_id": "your-thread-id",
"from": "[email protected]",
"body": "Hello from the partner",
"attachments": [],
"sent_at": "2026-04-23T20:00:00Z"
}
}Replays are safe — the (app_id, inbox_id, external_message_id) triple is the idempotency key (enforced by a unique DB index). Re-posting the same external_message_id for the same team returns the same message_id / conversation_id without creating a duplicate. Re-using the same external_message_id for a different team's install will still upsert a separate message because each team install has its own inbox_id.
conversation.created / conversation.updated — acknowledgement-only events for now. Sure Send accepts them but does not yet mutate server state.
Response
{
"accepted": true,
"conversation_id": "<sure_send_uuid>",
"message_id": "<sure_send_uuid>"
}Outbound webhooks
When a team member acts on a conversation in your inbox, Sure Send POSTs to your webhook_ingress_url with:
| Header | Value |
|---|---|
Content-Type | application/json |
X-SureSend-Event | Event type (message.sent, conversation.closed, etc.) |
X-SureSend-Signature | sha256=<hex_hmac> — same algorithm as inbound |
X-SureSend-Timestamp | Unix epoch seconds |
Verify the signature using your stored secret_key. A 2xx response is success. 4xx is a permanent failure (no retry). 5xx (or any transport error) triggers exponential-backoff retries: up to 5 attempts, with a per-attempt wait of executions⁴ seconds plus ±15% jitter (so retries fire at roughly 1s, 16s, 81s, 256s after the failing attempt). Per-request HTTP timeouts: 5s connect, 10s read — if your endpoint takes longer than that to respond 2xx, the delivery is treated as a 5xx and retried.
Event gating
An outbound event only fires when:
- It's listed in
outbound_eventson your app, and - The matching capability (if any) is declared in your app
| Event | Capability required |
|---|---|
message.sent | — (always, if declared) |
conversation.closed | close |
conversation.reopened | reopen |
conversation.assigned | — (always, if declared) |
install.completed | — (always, if declared) |
install.revoked | — (always, if declared) |
Install lifecycle events
install.completed fires on the transition into an installed state, after the user approves the consent screen. Re-installing an already-enabled app does not re-fire the event. Subscribe to this if you want to pre-provision per-team state when a team adopts your app, regardless of whether you've also picked up the bearer token via redirect_uri.
{
"event": "install.completed",
"emitted_at": "2026-05-15T18:00:00Z",
"team_id": "<team_uuid>",
"inbox_id": "<inbox_uuid>",
"inbox_app": {
"id": "<app_uuid>",
"slug": "acme-helper",
"name": "Acme Helper"
},
"installing_user": {
"id": "<user_uuid>",
"email": "[email protected]"
}
}install.revoked fires on uninstall and signals that any bearer tokens for this team have been destroyed. Tear down per-team state and stop pushing inbound traffic for this team_id. Like install.completed, it only fires on the transition — calling uninstall twice does not re-fire the event.
{
"event": "install.revoked",
"emitted_at": "2026-05-15T18:30:00Z",
"team_id": "<team_uuid>",
"inbox_id": "<inbox_uuid>",
"inbox_app": {
"id": "<app_uuid>",
"slug": "acme-helper",
"name": "Acme Helper"
}
}Example outbound payload
{
"event": "message.sent",
"emitted_at": "2026-04-23T21:00:00Z",
"team_id": "<team_uuid>",
"inbox_id": "<inbox_uuid>",
"conversation": {
"id": "<conversation_uuid>",
"status": "open",
"remote_address": "inbox_app:acme-helper:partner-thread-1",
"channel": "inbox_app",
"assigned_user_id": null,
"assigned_inbox_id": null,
"closed_at": null
},
"message": {
"id": "<suresend_message_uuid>",
"external_message_id": "uuid-from-suresend",
"direction": "outbound",
"body": "Reply text",
"from": "[email protected]",
"attachments": [],
"occurred_at": "2026-04-23T21:00:00Z"
}
}Partner REST API
With the bearer token minted at install, your backend can call:
GET /api/partner/inbox_apps/me
GET /api/partner/inbox_apps/meReturns the installed app context:
{
"inbox_app": { "id": "...", "slug": "acme-helper", "name": "Acme Helper", "capabilities": { ... }, "outbound_events": [...] },
"team": { "id": "...", "name": "..." },
"inbox": { "id": "...", "name": "...", "kind": "inbox_app" },
"token": { "scopes": ["read_inbox_messages","write_inbox_messages"], "scope": "team", "name": "Inbox App: Acme Helper" }
}GET /api/partner/inbox_apps/conversations
GET /api/partner/inbox_apps/conversationsList conversations in the installed inbox. Scope: read_inbox_conversations.
Query params: status=open|closed, limit (max 100, default 25), offset.
GET /api/partner/inbox_apps/conversations/:id
GET /api/partner/inbox_apps/conversations/:idShow a single conversation including the most recent 100 InboxAppMessage records. Scope: read_inbox_conversations.
PATCH /api/partner/inbox_apps/conversations/:id
PATCH /api/partner/inbox_apps/conversations/:idUpdate conversation state. Scope: write_inbox_conversations.
{ "status": "closed", "assigned_user_id": "<user_uuid>" }POST /api/partner/inbox_apps/conversations/:id/messages
POST /api/partner/inbox_apps/conversations/:id/messagesPost an outbound message from your platform. Scope: write_inbox_messages.
{
"body": "Message text",
"external_message_id": "optional-dedup-id",
"from": "optional-display-identifier",
"attachments": []
}Firing this endpoint is semantically equivalent to a team member replying from the Sure Send composer — the same message.sent outbound webhook fires, with your platform as the source.
Lifecycle operations
Rotate secret
From /integrations/inbox-apps or the admin console. Generates a new secret_key and reveals it once. All running instances of your app must update; the previous secret is immediately invalid.
Revoke install
A team admin can revoke your install from Sure Send settings. This:
- Sets the
InboxAppAuthorizationtorevoked - Destroys the bearer
ApiToken(subsequent calls return 401) - Soft-archives the inbox (
archived_atset; historical conversations remain readable)
Your webhooks will receive no further events for that team after revoke. Calls to the partner REST endpoints with the revoked token return 401 Unauthorized.
Uninstall vs. reinstall
Reinstalling a previously-revoked app re-enables the team setting, un-archives the inbox, and mints a fresh token. Conversation history is preserved.
Testing checklist
Happy path
- Register a test app at
/integrations/inbox-apps; copy the secret (revealed once). - Marketplace-initiated install: click the Test install button on your own app card (or the Install button on a Marketplace card) → consent screen → Approve. Verify your
webhook_ingress_urlreceives aninstall.completedevent withinstalling_user.email. Ifredirect_uriis set, your callback receives?code=...(nostate). - Partner-initiated install: from your own backend, generate a signed link with
partner_state=<your_csrf>and have a logged-in admin click it. After consent, your callback receives?code=...&state=<your_csrf>. Verify the state matches what you generated. - Exchange the code:
POST /api/inbox_app_authorizations/exchangewith{slug, secret_key, code}returns the bearer token. Retry the same code → expect401. - If you did NOT set a
redirect_uri, verify the consent page reveals the token in-line. - Uninstall → verify an
install.revokedevent fires. Repeat the uninstall (e.g. via the API directly) → verify it does not re-fire. - POST a
message.receivedwebhook with a dummy payload; verify a conversation appears in the inbox. - Re-POST the same payload (same
external_message_id); verify the response returns the samemessage_idandconversation_idand no duplicate appears (idempotency check). - Reply from the Sure Send composer; verify your webhook endpoint receives a
message.sentevent with a valid signature. - Close the conversation; verify
conversation.closedfires (only if you declared theclosecapability). Toggle the capability off and re-test — the event should stop firing. - Call the partner REST endpoints with your bearer token:
GET /api/partner/inbox_apps/me,GET /api/partner/inbox_apps/conversations,POST /api/partner/inbox_apps/conversations/:id/messages. The POST should also produce amessage.sentoutbound webhook. - Revoke the install from settings; verify subsequent webhook calls to your endpoint stop and your bearer token is rejected.
Negative paths
- Send a payload with a deliberately bad
X-SureSend-Signature→ expect401. - Send a payload with
X-SureSend-Timestamp>5 minutes off → expect401. - POST to
/api/inbox_apps/does-not-exist/webhookwith a valid signature for some other app → expect401(not404— slug existence is not leaked). - Burst >120 requests in one minute from a single IP → expect
429. - Call a partner endpoint without the required scope (e.g.
POST /messageswithoutwrite_inbox_messages) → expect403withMissing required scope: .... - Generate an install link with
partner_state=foo, then tamper with the URL to substitutepartner_state=attackerwhile leaving the signature unchanged → expect the consent page to redirect to an error. - Try the exchange endpoint with a wrong
secret_key→ expect401(uniform with all other failure modes — no enumeration).
Updated about 13 hours ago
