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:

  1. Partner registration — You register your app with Sure Send, receive a secret_key (signing key) and a slug (URL-routing element for inbound webhooks). Optionally register a redirect_uri to enable the OAuth-style token handoff.
  2. 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 ApiToken and either reveals it in-line or hands it off via a code-exchange redirect.
  3. Inbound webhooks — Your platform POSTs to https://<suresend>/api/inbox_apps/<slug>/webhook whenever something happens on your side (a message arrives, a conversation is created). Sure Send verifies the HMAC signature and materializes an InboxConversation inside the team's inbox.
  4. 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 an InboxAppAuthorization record.
  • 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:// (or http://localhost for 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

ScopeWhat it grants
read_peopleRead person/contact records (via the standard partner API)
write_peopleCreate/update people
read_inbox_conversationsList and show conversations in your installed inbox
write_inbox_conversationsClose/reopen/assign conversations
read_inbox_messagesRead messages on conversations in your inbox
write_inbox_messagesPost outbound messages into conversations
read_notes / write_notesRead and post notes on conversations

Capabilities

CapabilityEffect
attachmentsComposer shows the attach button; outbound events include attachments
read_receiptsComposer surfaces read receipts UI
closeSure Send fires conversation.closed webhooks to your endpoint
reopenSure Send fires conversation.reopened webhooks
channel_labelYour app can override the channel label in the conversation header
typing_indicatorsComposer 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 no partner_state in this flow — the install wasn't initiated from your site, so there's no session of yours to bind to. Your redirect_uri will receive ?code=... with no state=.
  • From your site: you generate the same kind of signed link from your backend and link to it. You should include a partner_state so 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:

Formsigned_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:

  1. Calls install_for_team! — creates a dedicated Inbox{kind: inbox_app} for the team and adds the consenting user as an inbox member.
  2. Mints an ApiToken with the scopes you requested. Token scope is team when your app has requires_team_authorization: true (the default; only team owners/admins can approve), or user when set to false (any member can approve; token is bound to that user).
  3. Hands the token to you — how depends on whether your app has a registered redirect_uri:

Without redirect_uri → user-clipboard handoff

Sure 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)

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 state is present on the redirect, verify it matches the partner_state you generated for the user's session before calling /exchange. When state is 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_uri must be https:// (or http://localhost for 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:

HeaderValue
Content-Typeapplication/json
X-SureSend-Signaturesha256=<hex_hmac> where hex_hmac = HMAC_SHA256(app.secret_key, "<X-SureSend-Timestamp>.<raw_body>")
X-SureSend-TimestampUnix 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:

HeaderValue
Content-Typeapplication/json
X-SureSend-EventEvent type (message.sent, conversation.closed, etc.)
X-SureSend-Signaturesha256=<hex_hmac> — same algorithm as inbound
X-SureSend-TimestampUnix 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:

  1. It's listed in outbound_events on your app, and
  2. The matching capability (if any) is declared in your app
EventCapability required
message.sent— (always, if declared)
conversation.closedclose
conversation.reopenedreopen
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

Returns 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

List 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

Show a single conversation including the most recent 100 InboxAppMessage records. Scope: read_inbox_conversations.

PATCH /api/partner/inbox_apps/conversations/:id

Update conversation state. Scope: write_inbox_conversations.

{ "status": "closed", "assigned_user_id": "<user_uuid>" }

POST /api/partner/inbox_apps/conversations/:id/messages

Post 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 InboxAppAuthorization to revoked
  • Destroys the bearer ApiToken (subsequent calls return 401)
  • Soft-archives the inbox (archived_at set; 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_url receives an install.completed event with installing_user.email. If redirect_uri is set, your callback receives ?code=... (no state).
  • 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/exchange with {slug, secret_key, code} returns the bearer token. Retry the same code → expect 401.
  • If you did NOT set a redirect_uri, verify the consent page reveals the token in-line.
  • Uninstall → verify an install.revoked event fires. Repeat the uninstall (e.g. via the API directly) → verify it does not re-fire.
  • POST a message.received webhook 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 same message_id and conversation_id and no duplicate appears (idempotency check).
  • Reply from the Sure Send composer; verify your webhook endpoint receives a message.sent event with a valid signature.
  • Close the conversation; verify conversation.closed fires (only if you declared the close capability). 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 a message.sent outbound 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 → expect 401.
  • Send a payload with X-SureSend-Timestamp >5 minutes off → expect 401.
  • POST to /api/inbox_apps/does-not-exist/webhook with a valid signature for some other app → expect 401 (not 404 — 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 /messages without write_inbox_messages) → expect 403 with Missing required scope: ....
  • Generate an install link with partner_state=foo, then tamper with the URL to substitute partner_state=attacker while leaving the signature unchanged → expect the consent page to redirect to an error.
  • Try the exchange endpoint with a wrong secret_key → expect 401 (uniform with all other failure modes — no enumeration).

Did this page help you?