SendFlo

Secure intakes

A secure intake is a short-lived upload page that a bot, an assistant or a helpdesk opens for one person in one conversation: "upload a photo of your ID", "send us the invoice". The person uploads without an account. The bot sees that the files arrived, with their type, size and malware scan. It never sees the files themselves. The people on your team they're handed to open them in SendFlo, and every open is recorded.

Open one

curl https://api.sendflo.app/v1/intakes \
  -H "Authorization: Bearer $SENDFLO_API_KEY" -H "Content-Type: application/json" \
  -d '{
    "prompt": "Upload a photo of your ID",
    "requested_by": "Acme support",
    "external_refs": { "provider": "zendesk", "kind": "ticket", "id": "4821", "label": "Ticket #4821" },
    "questions": [{ "id": "case", "label": "Case number", "type": "short_text" }],
    "locked_answers": { "case": "C-4821" },
    "agent_fields": { "answers": ["case"] },
    "handoff": { "to": [{ "email": "dana@acme.com" }] },
    "widget_origin": "https://help.acme.com"
  }'

You get back upload_url (the page to give the person) and client_token (for the pop-up below). It closes after one upload and expires in 24 hours by default (15 minutes to 7 days). With send_to, SendFlo emails the person the link and only they can upload. requested_by and the locked answers show on the page, and the person can't change them. The key needs file_requests.write. An API & Agents workspace pays 1 credit, as for a file request.

What the bot can never do

What an intake collects is closed to every API credential: API keys, apps' OAuth tokens, and so MCP and A2A clients too. Download, preview, links, send again and agent attachments answer 403 intake_content_blind, whatever the key's scopes. Every refusal is in the audit log, and three within an hour raise a key.anomaly (content_probe). Lists show "Collected file.png" instead of the name, and leave out the uploader and any answers the intake doesn't share (agent_fields).

If a bot really must read what it collects, make the intake with agent_access: "read". On a team this works only after the owner or an admin allows it, under Team → Policy → Secure intakes. The page then tells the uploader that the assistant can read what they send.

Status, events and hand-offs

Poll GET /v1/intakes/{id}, or listen for events. The status goes open → opened → submitted → ready (every file stored and scanned clean), or else rejected, expired or closed. The events are intake.submitted, intake.ready, intake.rejected, intake.expired and intake.handed_off. Each carries the same metadata and your external_refs, so your bot can answer in the right ticket.

# Hand the files to a teammate (or a group: {"group_id": "…"}), view or manage, with an optional end
curl -X POST https://api.sendflo.app/v1/intakes/$ID/handoff \
  -H "Authorization: Bearer $SENDFLO_API_KEY" -H "Content-Type: application/json" \
  -d '{"to": {"email": "lee@acme.com", "permission": "view"}, "expires_at": "2026-12-01T00:00:00Z"}'

# Take it back
curl -X DELETE https://api.sendflo.app/v1/intakes/$ID/handoff \
  -H "Authorization: Bearer $SENDFLO_API_KEY" -H "Content-Type: application/json" \
  -d '{"kind": "user", "id": "<grantee_id from handed_to>"}'

People find handed files in SendFlo → Received → Handed to me, with who handed them and for which ticket. They can open them under the file's own rules (view-only pages stay view-only). The file's owner sees each open under Who opened my files. Hand-offs go only to people on the team: whoever holds a seat, the owner, admins and managers. On the web, the owner of a received file can also hand it on with Hand to….

In your chat: SendFlo.requestUpload

Rather than posting the link in the conversation, where a transcript would keep a working upload link, give your chat page the client_token. It works once, for 15 minutes. The pop-up opens only on the site you named in widget_origin.

<script src="https://sendflo.app/widget/v1.js" defer></script>
<script>
  // token: the client_token your bot got from POST /v1/intakes
  SendFlo.requestUpload({
    token,
    onEvent: (type, data) => { if (type === "all_complete") chat.say("Thanks, got it."); },
  });
</script>

A used or expired token raises error with token_used or intake_closed, and another site gets wrong_site. For the pinned loader with its integrity hash, see the upload widget.

SDKs, CLI, MCP and A2A

const intake = await sendflo.createIntake({ prompt: "Upload the invoice", requestedBy: "Acme billing",
  externalRefs: { provider: "intercom", kind: "conversation", id: convId }, handoff: { to: [{ email: "ap@acme.com" }] } });
const done = await sendflo.waitForIntake(intake.id); // ready | rejected | expired | closed — never the file
sendflo intake "Upload the invoice" --requested-by "Acme billing" --ref intercom:conversation:123 --hand-to ap@acme.com --wait
sendflo intake show <id>
sendflo intake hand <id> --to lee@acme.com

Python has the same calls: create_intake, get_intake, hand_off_intake and wait_for_intake. In MCP, request_file_from_human with secure: true opens one, check_file_request reads it and hand_off_files hands it on. Over A2A, the request_file skill opens an intake keyed by your contextId. The task completes with what arrived, never the file.

Recipes for helpdesk bots

Each recipe works the same way. Store a SendFlo API key in the tool's secret store, then call POST /v1/intakes when the bot needs a file. Pass the conversation as external_refs, and show the person upload_url (or use requestUpload on your own site). Answer in the conversation when intake.ready arrives on your webhook. Make the key in a team's workspace so hand-offs can go to its people.

  • Zendesk AI agents: an API integration in the AI agent's settings. Use the ticket id as the reference: {provider: "zendesk", kind: "ticket", id}.
  • Intercom Fin: a data connector (an HTTPS action Fin can call). Use the conversation id: {provider: "intercom", kind: "conversation", id}.
  • Salesforce Agentforce: an agent action backed by a Flow or Apex that calls an external service. Use the Case id: {provider: "salesforce", kind: "case", id}.
  • Ada: an action that makes an HTTP request. Use the conversation id: {provider: "ada", kind: "conversation", id}.
  • Crisp (Hugo): an action or bot scenario step that makes an HTTP request. Use the session id: {provider: "crisp", kind: "session", id}.
  • Voiceflow: an API step (or a function). Use the user's session or conversation id: {provider: "voiceflow", kind: "session", id}.
  • Botpress: an Execute Code card or an API call. Use the conversation id: {provider: "botpress", kind: "conversation", id}.

Leave agent_access at its default (none). The bot needs to know a file arrived and is clean, never what is in it.