# Assign phone number to campaign Source: https://docs.zavu.dev/api-reference/10dlc/assign-phone-number-to-campaign /openapi.json post /v1/10dlc/campaigns/{campaignId}/phone-numbers Assign a US phone number to an approved 10DLC campaign. The campaign must be in approved status. # Create 10DLC brand Source: https://docs.zavu.dev/api-reference/10dlc/create-10dlc-brand /openapi.json post /v1/10dlc/brands Create a 10DLC brand registration. The brand starts in draft status. Submit it for review using the submit endpoint. # Create 10DLC campaign Source: https://docs.zavu.dev/api-reference/10dlc/create-10dlc-campaign /openapi.json post /v1/10dlc/campaigns Create a 10DLC campaign under an existing brand. The campaign starts in draft status. Submit it for carrier review using the submit endpoint. # Delete 10DLC brand Source: https://docs.zavu.dev/api-reference/10dlc/delete-10dlc-brand /openapi.json delete /v1/10dlc/brands/{brandId} # Delete 10DLC campaign Source: https://docs.zavu.dev/api-reference/10dlc/delete-10dlc-campaign /openapi.json delete /v1/10dlc/campaigns/{campaignId} # Get 10DLC brand Source: https://docs.zavu.dev/api-reference/10dlc/get-10dlc-brand /openapi.json get /v1/10dlc/brands/{brandId} # Get 10DLC campaign Source: https://docs.zavu.dev/api-reference/10dlc/get-10dlc-campaign /openapi.json get /v1/10dlc/campaigns/{campaignId} # List 10DLC brands Source: https://docs.zavu.dev/api-reference/10dlc/list-10dlc-brands /openapi.json get /v1/10dlc/brands List 10DLC brand registrations for this project. # List 10DLC campaigns Source: https://docs.zavu.dev/api-reference/10dlc/list-10dlc-campaigns /openapi.json get /v1/10dlc/campaigns List 10DLC campaign registrations for this project. # List 10DLC use cases Source: https://docs.zavu.dev/api-reference/10dlc/list-10dlc-use-cases /openapi.json get /v1/10dlc/brands/use-cases List available use cases for 10DLC campaign registration. # List campaign phone number assignments Source: https://docs.zavu.dev/api-reference/10dlc/list-campaign-phone-number-assignments /openapi.json get /v1/10dlc/campaigns/{campaignId}/phone-numbers List phone numbers assigned to a 10DLC campaign. A number that was unassigned is no longer listed. # Submit 10DLC brand for review Source: https://docs.zavu.dev/api-reference/10dlc/submit-10dlc-brand-for-review /openapi.json post /v1/10dlc/brands/{brandId}/submit Submit a draft brand to The Campaign Registry (TCR) for vetting. The brand must be in draft status, and the team must have an approved Business Verification (KYB) — carriers register a brand against a vetted legal entity, so submitting without one returns `403` with code `kyb_required`. TCR's one-time $4 brand registration fee is charged from your balance at submission (passed through at cost) and refunded if the carrier rejects the registration. The fee is per BRAND: a team registering a second legal entity pays it again. A brand already paid for through the compliance flow is not charged twice. Campaign registration is billed separately when a campaign is submitted. # Submit 10DLC campaign for review Source: https://docs.zavu.dev/api-reference/10dlc/submit-10dlc-campaign-for-review /openapi.json post /v1/10dlc/campaigns/{campaignId}/submit The team must have an approved Business Verification (KYB): submitting without one returns `403` with code `kyb_required`. Submit a draft campaign for carrier review. The campaign must be in draft status and its brand must be verified. TCR's one-time registration fee is charged from your balance at submission ($15 for standard use cases, $2 for LOW_VOLUME), passed through at cost and refunded if the carrier rejects it. Once approved, the campaign's monthly TCR fee ($10 standard, $2 LOW_VOLUME) is charged from your balance while the campaign is active — see registrationCostCents and monthlyFeeCents on the campaign object. # Sync 10DLC brand status Source: https://docs.zavu.dev/api-reference/10dlc/sync-10dlc-brand-status /openapi.json post /v1/10dlc/brands/{brandId}/sync Sync the brand status with the registration provider. Use this to check for approval updates after submission. # Sync 10DLC campaign status Source: https://docs.zavu.dev/api-reference/10dlc/sync-10dlc-campaign-status /openapi.json post /v1/10dlc/campaigns/{campaignId}/sync Sync the campaign status with the registration provider. Use this to check for approval updates after submission. # Unassign phone number from campaign Source: https://docs.zavu.dev/api-reference/10dlc/unassign-phone-number-from-campaign /openapi.json delete /v1/10dlc/campaigns/{campaignId}/phone-numbers/{assignmentId} Remove a phone number assignment from a 10DLC campaign. # Update 10DLC brand Source: https://docs.zavu.dev/api-reference/10dlc/update-10dlc-brand /openapi.json patch /v1/10dlc/brands/{brandId} Update a 10DLC brand in draft status. Cannot update after submission. # Update 10DLC campaign Source: https://docs.zavu.dev/api-reference/10dlc/update-10dlc-campaign /openapi.json patch /v1/10dlc/campaigns/{campaignId} Update a 10DLC campaign in draft status. Cannot update after submission. # Get current API key context Source: https://docs.zavu.dev/api-reference/account/get-current-api-key-context /openapi.json get /v1/me Returns the project, team, and API key metadata bound to the current Bearer token. Used by CLIs and SDKs to confirm which project they will operate on. # Add channel to contact Source: https://docs.zavu.dev/api-reference/add-channel-to-contact /openapi.json post /v1/contacts/{contactId}/channels Add a new communication channel to an existing contact. # Add contacts to broadcast Source: https://docs.zavu.dev/api-reference/add-contacts-to-broadcast /openapi.json post /v1/broadcasts/{broadcastId}/contacts Add contacts to a broadcast in batch. Maximum 1000 contacts per request. # Create flow Source: https://docs.zavu.dev/api-reference/agent-flows/create-flow /openapi.json post /v1/senders/{senderId}/agent/flows Create a new flow for an agent. # Delete flow Source: https://docs.zavu.dev/api-reference/agent-flows/delete-flow /openapi.json delete /v1/senders/{senderId}/agent/flows/{flowId} Delete a flow. Cannot delete flows with active sessions. # Duplicate flow Source: https://docs.zavu.dev/api-reference/agent-flows/duplicate-flow /openapi.json post /v1/senders/{senderId}/agent/flows/{flowId}/duplicate Create a copy of an existing flow with a new name. # Get flow Source: https://docs.zavu.dev/api-reference/agent-flows/get-flow /openapi.json get /v1/senders/{senderId}/agent/flows/{flowId} Get a specific flow. # List flows Source: https://docs.zavu.dev/api-reference/agent-flows/list-flows /openapi.json get /v1/senders/{senderId}/agent/flows List flows for an agent. # Update flow Source: https://docs.zavu.dev/api-reference/agent-flows/update-flow /openapi.json patch /v1/senders/{senderId}/agent/flows/{flowId} Update a flow. # Create document Source: https://docs.zavu.dev/api-reference/agent-knowledge-bases/create-document /openapi.json post /v1/senders/{senderId}/agent/knowledge-bases/{kbId}/documents Add a document to a knowledge base. The document will be automatically processed for RAG. # Create knowledge base Source: https://docs.zavu.dev/api-reference/agent-knowledge-bases/create-knowledge-base /openapi.json post /v1/senders/{senderId}/agent/knowledge-bases Create a new knowledge base for an agent. # Delete document Source: https://docs.zavu.dev/api-reference/agent-knowledge-bases/delete-document /openapi.json delete /v1/senders/{senderId}/agent/knowledge-bases/{kbId}/documents/{docId} Delete a document from a knowledge base. # Delete knowledge base Source: https://docs.zavu.dev/api-reference/agent-knowledge-bases/delete-knowledge-base /openapi.json delete /v1/senders/{senderId}/agent/knowledge-bases/{kbId} Delete a knowledge base and all its documents. # Get document Source: https://docs.zavu.dev/api-reference/agent-knowledge-bases/get-document /openapi.json get /v1/senders/{senderId}/agent/knowledge-bases/{kbId}/documents/{docId} Get a single document from a knowledge base. # Get knowledge base Source: https://docs.zavu.dev/api-reference/agent-knowledge-bases/get-knowledge-base /openapi.json get /v1/senders/{senderId}/agent/knowledge-bases/{kbId} Get a specific knowledge base. # List documents Source: https://docs.zavu.dev/api-reference/agent-knowledge-bases/list-documents /openapi.json get /v1/senders/{senderId}/agent/knowledge-bases/{kbId}/documents List documents in a knowledge base. # List knowledge bases Source: https://docs.zavu.dev/api-reference/agent-knowledge-bases/list-knowledge-bases /openapi.json get /v1/senders/{senderId}/agent/knowledge-bases List knowledge bases for an agent. # Update document Source: https://docs.zavu.dev/api-reference/agent-knowledge-bases/update-document /openapi.json patch /v1/senders/{senderId}/agent/knowledge-bases/{kbId}/documents/{docId} Update a document's title or content. Updating content reprocesses the document for RAG. # Update knowledge base Source: https://docs.zavu.dev/api-reference/agent-knowledge-bases/update-knowledge-base /openapi.json patch /v1/senders/{senderId}/agent/knowledge-bases/{kbId} Update a knowledge base. # Create tool Source: https://docs.zavu.dev/api-reference/agent-tools/create-tool /openapi.json post /v1/senders/{senderId}/agent/tools Create a new tool for an agent. Tools allow the agent to call external webhooks. # Delete tool Source: https://docs.zavu.dev/api-reference/agent-tools/delete-tool /openapi.json delete /v1/senders/{senderId}/agent/tools/{toolId} Delete a tool. # Get tool Source: https://docs.zavu.dev/api-reference/agent-tools/get-tool /openapi.json get /v1/senders/{senderId}/agent/tools/{toolId} Get a specific tool. # List tool test runs Source: https://docs.zavu.dev/api-reference/agent-tools/list-tool-test-runs /openapi.json get /v1/senders/{senderId}/agent/tools/{toolId}/test-runs Recent runs of this tool triggered from the test endpoint, newest first. Covers manual tests only: a tool called by an agent during a real conversation is not recorded here. # List tools Source: https://docs.zavu.dev/api-reference/agent-tools/list-tools /openapi.json get /v1/senders/{senderId}/agent/tools List tools for an agent. # Rotate a tool's webhook secret Source: https://docs.zavu.dev/api-reference/agent-tools/rotate-a-tools-webhook-secret /openapi.json post /v1/senders/{senderId}/agent/tools/{toolId}/webhook/secret Generate a new signing secret for this tool. The previous one stops working on the next call, with no overlap, so update your endpoint first. The tool keeps its id, so flows that reference it by name are unaffected. # Test tool Source: https://docs.zavu.dev/api-reference/agent-tools/test-tool /openapi.json post /v1/senders/{senderId}/agent/tools/{toolId}/test Run a tool with the parameters you supply and return what it answered. The call is synchronous: the response carries the tool's status, body, and duration, so a green result is evidence the tool ran rather than evidence it was accepted. Each run is also recorded and readable afterwards via `GET /v1/senders/{senderId}/agent/tools/{toolId}/test-runs`. A tool that answers with an error is reported as a run with `success: false` — the endpoint itself still returns 200. This fires the tool's real webhook, so a test has whatever side effects the tool has. # Update tool Source: https://docs.zavu.dev/api-reference/agent-tools/update-tool /openapi.json patch /v1/senders/{senderId}/agent/tools/{toolId} Update a tool. # Connect a sender to an agent Source: https://docs.zavu.dev/api-reference/agents/connect-a-sender-to-an-agent /openapi.json post /v1/agents/{agentId}/senders Make the agent answer on this sender. An agent can serve several senders; a sender answers with at most one agent, so connecting one that is already in use returns `400` naming the agent that holds it. # Create a standalone agent Source: https://docs.zavu.dev/api-reference/agents/create-a-standalone-agent /openapi.json post /v1/agents Create an agent without a sender. It is created disabled; connect a sender and enable it when you are ready for it to answer. **Sub-resources.** An agent's tools, flows and knowledge bases are reachable at `/v1/agents/{agentId}/tools`, `/v1/agents/{agentId}/flows` and `/v1/agents/{agentId}/knowledge-bases`, mirroring the sender-scoped routes documented under `/v1/senders/{senderId}/agent/...` exactly. Use the agent-scoped form while the agent has no sender: the sender-scoped one cannot address it. # Create agent Source: https://docs.zavu.dev/api-reference/agents/create-agent /openapi.json post /v1/senders/{senderId}/agent Create an AI agent for a sender. Each sender can have at most one agent. # Delete agent Source: https://docs.zavu.dev/api-reference/agents/delete-agent /openapi.json delete /v1/senders/{senderId}/agent Delete the agent created on this sender. A sender that is only connected to an agent through `POST /v1/agents/{agentId}/senders` is refused with `409`: deleting there would remove the agent from every sender it answers on. Disconnect it with `DELETE /v1/agents/{agentId}/senders/{senderId}`, or delete the agent with `DELETE /v1/agents/{agentId}`. # Delete an agent Source: https://docs.zavu.dev/api-reference/agents/delete-an-agent /openapi.json delete /v1/agents/{agentId} # Disconnect a sender from an agent Source: https://docs.zavu.dev/api-reference/agents/disconnect-a-sender-from-an-agent /openapi.json delete /v1/agents/{agentId}/senders/{senderId} Stop the agent answering on this sender. The agent's primary sender is part of the agent itself and cannot be disconnected here. # Get a factory agent Source: https://docs.zavu.dev/api-reference/agents/get-a-factory-agent /openapi.json get /v1/agent-templates/{templateId} Fetch a single factory agent fully rendered: the function files to scaffold (an `index.ts` that declares the agent with `defineAgent` and its skills with `defineTool`) plus the secrets it needs. This is what `npx zavudev agents pull ` writes to disk before `npx zavudev deploy`. # Get a single agent execution Source: https://docs.zavu.dev/api-reference/agents/get-a-single-agent-execution /openapi.json get /v1/senders/{senderId}/agent/executions/{executionId} Fetch full details for one execution — including `errorMessage`, `errorCode`, and `responseText`. Use this to debug failures surfaced by the list endpoint. # Get agent Source: https://docs.zavu.dev/api-reference/agents/get-agent /openapi.json get /v1/senders/{senderId}/agent Get the AI agent configuration for a sender. # Get agent statistics Source: https://docs.zavu.dev/api-reference/agents/get-agent-statistics /openapi.json get /v1/senders/{senderId}/agent/stats Get statistics for an AI agent including invocations, tokens, and costs. Covers the messaging channels only. Voice calls are not counted here: a call is a multi-turn conversation rather than one inbound message and one reply, so it is recorded as a call, not an execution. An agent that only answers phone calls reports zeros on every field. Use `GET /v1/calls` for voice activity, duration, and cost. # Get an agent Source: https://docs.zavu.dev/api-reference/agents/get-an-agent /openapi.json get /v1/agents/{agentId} # List agent executions Source: https://docs.zavu.dev/api-reference/agents/list-agent-executions /openapi.json get /v1/senders/{senderId}/agent/executions List recent agent executions with pagination. An execution is one inbound message answered by the agent, so this covers the messaging channels only. Voice calls are never listed here regardless of how many the agent handled. Use `GET /v1/calls` (and `GET /v1/calls/{callId}` for the transcript) for voice. # List agent voices Source: https://docs.zavu.dev/api-reference/agents/list-agent-voices /openapi.json get /v1/agents/voices The voices an agent can speak with, for `voice.ttsVoiceId`. Filter by `language` to get the ones that speak it; a voice can still be used with `language: auto`, where the agent follows the caller and keeps the chosen voice. # List agents Source: https://docs.zavu.dev/api-reference/agents/list-agents /openapi.json get /v1/agents Every agent in the project, newest first — including agents that are not connected to any sender yet, which the sender-scoped routes cannot reach. Each item carries `senderIds`, the senders the agent answers on. # List factory agents Source: https://docs.zavu.dev/api-reference/agents/list-factory-agents /openapi.json get /v1/agent-templates List the factory agents available to scaffold with `npx zavudev agents pull`. Each entry is a ready-made voice or text agent (system prompt, skills, and — for voice agents — a co-located voice config). # Test an agent Source: https://docs.zavu.dev/api-reference/agents/test-an-agent /openapi.json post /v1/agents/{agentId}/test Run the agent's prompt, model and knowledge base against a message and return the reply instead of delivering it. Nothing is delivered and no execution is recorded. **Billed like a live reply.** On an agent with the `zavu` provider the run is charged to your balance at the same price per token, and refused with `402 insufficient_balance` at a balance of zero; on a bring-your-own-key agent it costs your provider. `cost` on the response is what it charged. Runs are rate limited per project (`429`). **Tools.** By default the model is offered the agent's enabled tools but none is run: if it picks one, the reply stops there and `plannedToolCalls` says which, with what arguments. Set `executeTools` to run them — that reaches your real handlers, with whatever they cost and change — and read `executedToolCalls`. **Test-mode keys** are billed the same way, and `executeTools` with one still reaches your real handlers and writes real memory: a test run is not sandboxed by the key's mode. # Update agent Source: https://docs.zavu.dev/api-reference/agents/update-agent /openapi.json patch /v1/senders/{senderId}/agent Update an AI agent's configuration. # Update an agent Source: https://docs.zavu.dev/api-reference/agents/update-an-agent /openapi.json patch /v1/agents/{agentId} # Get balance Source: https://docs.zavu.dev/api-reference/balance/get-balance /openapi.json get /v1/balance Get balance for the API key's team. If the API key belongs to a sub-account, also includes the sub-account's total spending and credit limit. Also reports the team's app-message allowance, whether it is paused and why, pay as you go and the spend cap, and how many incoming messages are held: the way to learn that customers are writing without sending anything. # Cancel broadcast Source: https://docs.zavu.dev/api-reference/cancel-broadcast /openapi.json post /v1/broadcasts/{broadcastId}/cancel Cancel a broadcast. Pending contacts will be skipped, but already queued messages may still be delivered. A broadcast can be cancelled from `draft`, `pending_review`, `pending_admin_review`, `approved`, `rejected`, `escalated`, `scheduled` and `sending`. Any balance reserved for it is released. A review still running when it is cancelled cannot bring it back. # Cancel invitation Source: https://docs.zavu.dev/api-reference/cancel-invitation /openapi.json post /v1/invitations/{invitationId}/cancel Cancel an active invitation. The client will no longer be able to use the invitation link. # Configure project webhook Source: https://docs.zavu.dev/api-reference/configure-project-webhook /openapi.json post /v1/invitations/webhook Create or replace the project webhook for the project behind the API key, and activate it. The response carries the signing `secret`; an existing secret is kept. This is where `invitation.status_changed` is delivered. Events are scoped to this project: when you onboard clients in sub-accounts, call this with each sub-account's API key. # Get conversation Source: https://docs.zavu.dev/api-reference/conversations/get-conversation /openapi.json get /v1/conversations/{conversationId} # List conversation messages Source: https://docs.zavu.dev/api-reference/conversations/list-conversation-messages /openapi.json get /v1/conversations/{conversationId}/messages Messages in this thread, newest first, across every channel it has carried. Reply with `POST /v1/messages`, passing the conversation's `senderId` as the `Zavu-Sender` header so the answer leaves from the number the contact already knows. # List conversations Source: https://docs.zavu.dev/api-reference/conversations/list-conversations /openapi.json get /v1/conversations List inbox threads, most recently active first. A conversation groups every message with one contact across channels, which is what you need to build an inbox: `GET /v1/messages` returns a flat log with no thread to hang it on. Use `senderId` to scope the list to a single number, and `channel` to keep only threads that have carried that channel. # Mark conversation as read Source: https://docs.zavu.dev/api-reference/conversations/mark-conversation-as-read /openapi.json post /v1/conversations/{conversationId}/read Reset the thread's `unreadCount` to zero. Marks the thread read in your own inbox only: it does not send a read receipt to the contact. # Create broadcast Source: https://docs.zavu.dev/api-reference/create-broadcast /openapi.json post /v1/broadcasts Create a new broadcast campaign. Add contacts after creation, then send. # Create contact Source: https://docs.zavu.dev/api-reference/create-contact /openapi.json post /v1/contacts Create a new contact with one or more communication channels. # Create invitation Source: https://docs.zavu.dev/api-reference/create-invitation /openapi.json post /v1/invitations Create a partner invitation link for a client to connect a Meta channel. The client opens the returned `url` and authorizes with Meta; the resulting sender is created in your project when they finish, and the invitation transitions to `completed`. `connectionType` picks the channel: - `whatsapp_waba` (default): Meta's embedded signup links an official WhatsApp Business Account. - `messenger`: the client picks a Facebook Page they administer; its Messenger inbox (including Marketplace chats) is routed to Zavu. One invitation connects one channel — create one per channel to onboard a client on several. `phoneNumberId` and `allowedPhoneCountries` apply to `whatsapp_waba` only. # Create sender Source: https://docs.zavu.dev/api-reference/create-sender /openapi.json post /v1/senders # Create template Source: https://docs.zavu.dev/api-reference/create-template /openapi.json post /v1/templates Create a WhatsApp message template. Note: Templates must be approved by Meta before use. # Delete broadcast Source: https://docs.zavu.dev/api-reference/delete-broadcast /openapi.json delete /v1/broadcasts/{broadcastId} Delete a broadcast in draft status. # Delete contact Source: https://docs.zavu.dev/api-reference/delete-contact /openapi.json delete /v1/contacts/{contactId} Permanently delete a contact and its communication channels. Implements right-to-erasure obligations under GDPR Art. 17, Ley 19.628 (Chile) Art. 12, CCPA § 1798.105, and LGPD Art. 18.VI. The contact, its channels and any associated agent flow sessions are removed. Conversation threads, message history and broadcast delivery logs are kept and no longer reference the deleted contact. # Delete sender Source: https://docs.zavu.dev/api-reference/delete-sender /openapi.json delete /v1/senders/{senderId} Delete a sender. The project's default sender cannot be deleted while other senders exist: set another as default first. The default can be deleted when it is the project's only sender, and then the project has no default: every send that names no sender (no `Zavu-Sender` header) fails until another sender exists and is set as default. # Delete template Source: https://docs.zavu.dev/api-reference/delete-template /openapi.json delete /v1/templates/{templateId} # Add an email domain Source: https://docs.zavu.dev/api-reference/email-domains/add-an-email-domain /openapi.json post /v1/email-domains Add a domain to send email from. Returns the DNS records to publish (DKIM CNAMEs are required; SPF, DMARC, and MAIL FROM are recommended). Publish them at your DNS provider, then verify. # Get an email domain Source: https://docs.zavu.dev/api-reference/email-domains/get-an-email-domain /openapi.json get /v1/email-domains/{domainId} Fetch a domain with its DNS records and current status. # List email domains Source: https://docs.zavu.dev/api-reference/email-domains/list-email-domains /openapi.json get /v1/email-domains # Remove an email domain Source: https://docs.zavu.dev/api-reference/email-domains/remove-an-email-domain /openapi.json delete /v1/email-domains/{domainId} # Verify an email domain Source: https://docs.zavu.dev/api-reference/email-domains/verify-an-email-domain /openapi.json post /v1/email-domains/{domainId}/verify Re-check the domain's published DNS records and refresh its status. # Escalate to manual review Source: https://docs.zavu.dev/api-reference/escalate-to-manual-review /openapi.json post /v1/broadcasts/{broadcastId}/escalate Request manual review by the Zavu team for a rejected broadcast. Use this after automated review rejection if you believe the content is legitimate. # Create function Source: https://docs.zavu.dev/api-reference/functions/create-function /openapi.json post /v1/functions Create a new Zavu Function. The function starts in `draft` status. A dedicated API key is auto-provisioned and injected as the `ZAVU_API_KEY` secret so the function can call back into the Zavu API without manual setup. Provide `sourceCode` to seed the draft. Call `POST /v1/functions/{functionId}/deploy` afterwards to publish. # Create function triggers Source: https://docs.zavu.dev/api-reference/functions/create-function-triggers /openapi.json post /v1/functions/{functionId}/triggers Subscribe a function to one or more event types, optionally scoped to specific senders. Provide eventTypes and senderIds (use null in senderIds for all senders); a trigger is created for each event type and sender combination. The special event type `cron` runs the function on a schedule instead of a messaging event: include a `cron` field with a 5-field UTC cron expression (minimum granularity one minute). A cron trigger ignores the sender axis, and a function may hold several cron triggers with different expressions. The function receives an event with `type: "cron"` and `data.cron`. # Delete a trigger Source: https://docs.zavu.dev/api-reference/functions/delete-a-trigger /openapi.json delete /v1/functions/triggers/{triggerId} # Delete function Source: https://docs.zavu.dev/api-reference/functions/delete-function /openapi.json delete /v1/functions/{functionId} Permanently delete a function and cascade: triggers, secrets, deployment history, managed agents+tools, and revoke the auto-provisioned API key. The AWS Lambda + log group are torn down asynchronously. # Deploy function Source: https://docs.zavu.dev/api-reference/functions/deploy-function /openapi.json post /v1/functions/{functionId}/deploy Publish the function. If `sourceCode` or `dependencies` are provided in the body, they replace the current draft before deployment. Returns immediately with a deployment ID — poll `GET /v1/functions/deployments/{deploymentId}` until status is `active` or `failed`. # Deploy the linked branch now Source: https://docs.zavu.dev/api-reference/functions/deploy-the-linked-branch-now /openapi.json post /v1/functions/{functionId}/git-link/deploy Fetch the linked branch and deploy it without waiting for a push. Returns immediately; follow the outcome with `GET /v1/functions/{functionId}/git-link`, whose `lastStatus` and `lastError` describe the run. # Enable or disable a trigger Source: https://docs.zavu.dev/api-reference/functions/enable-or-disable-a-trigger /openapi.json patch /v1/functions/triggers/{triggerId} # Get deployment Source: https://docs.zavu.dev/api-reference/functions/get-deployment /openapi.json get /v1/functions/deployments/{deploymentId} Fetch a deployment to poll its status during a deploy. # Get function Source: https://docs.zavu.dev/api-reference/functions/get-function /openapi.json get /v1/functions/{functionId} # Get the linked repository Source: https://docs.zavu.dev/api-reference/functions/get-the-linked-repository /openapi.json get /v1/functions/{functionId}/git-link The link and its last deploy. Never returns the webhook secret. # Link a GitHub repository Source: https://docs.zavu.dev/api-reference/functions/link-a-github-repository /openapi.json post /v1/functions/{functionId}/git-link Bind a repository to this function so every push to `branch` deploys it. A function holds at most one link; linking again returns 400. **The server decides how the link authenticates.** If the project has the Zavu GitHub App installed, the link uses that installation: private repositories work and there is nothing to configure in the repository. Otherwise it falls back to a manual link and the response carries a `webhookSecret` you add to the repository yourself. `connection` says which one you got. The repository is not checked against GitHub here, because it cannot be: an owner/repo that does not exist, or that the installation cannot see, is accepted and fails on the first deploy with a fetch error. # List function deployments Source: https://docs.zavu.dev/api-reference/functions/list-function-deployments /openapi.json get /v1/functions/{functionId}/deployments List a function's deployment history, newest first. Source code is omitted; fetch a single deployment via GET /v1/functions/deployments/{deploymentId} for full details. # List function event types Source: https://docs.zavu.dev/api-reference/functions/list-function-event-types /openapi.json get /v1/functions/event-types List the event types a function trigger can subscribe to. Includes the special type `cron`, which fires on a schedule (see POST /v1/functions/{functionId}/triggers) rather than on a messaging event. # List function secrets Source: https://docs.zavu.dev/api-reference/functions/list-function-secrets /openapi.json get /v1/functions/{functionId}/secrets Lists every secret key set on the function. Plaintext is NEVER returned — only the last 4 characters of each value, for visual confirmation. # List function triggers Source: https://docs.zavu.dev/api-reference/functions/list-function-triggers /openapi.json get /v1/functions/{functionId}/triggers # Roll back to a previous deployment Source: https://docs.zavu.dev/api-reference/functions/roll-back-to-a-previous-deployment /openapi.json post /v1/functions/{functionId}/rollback Re-deploy a previous version by copying its source, dependencies, and runtime pin onto the function's draft, then deploying. Returns immediately with a deployment ID — poll GET /v1/functions/deployments/{deploymentId} until status is active or failed. Secrets are not rolled back. # Set function secret Source: https://docs.zavu.dev/api-reference/functions/set-function-secret /openapi.json put /v1/functions/{functionId}/secrets/{key} Create or update a secret on a function. Marks the function out-of-sync; the next `POST /deploy` re-publishes the Lambda with the new env. Keys must match `[A-Z_][A-Z0-9_]*` (uppercase env-var style) and cannot start with reserved prefixes (AWS_, LAMBDA_, etc). # Tail function logs Source: https://docs.zavu.dev/api-reference/functions/tail-function-logs /openapi.json get /v1/functions/{functionId}/logs Fetch invocation logs for a function. Logs are paginated via `nextToken`. Pass `startTime` / `endTime` (Unix epoch milliseconds) to bound the window, or `filterPattern` to filter messages. # Unlink the repository Source: https://docs.zavu.dev/api-reference/functions/unlink-the-repository /openapi.json delete /v1/functions/{functionId}/git-link Remove the link. The function and its deployments stay. A manual webhook left in the repository stops being accepted, so remove it there too. # Unset function secret Source: https://docs.zavu.dev/api-reference/functions/unset-function-secret /openapi.json delete /v1/functions/{functionId}/secrets/{key} Remove a secret from a function. Doesn't take effect on the running Lambda until the next deploy. # Update function draft Source: https://docs.zavu.dev/api-reference/functions/update-function-draft /openapi.json patch /v1/functions/{functionId} Update an existing function. `sourceCode` / `dependencies` edit the draft without triggering a build — they go live on the next `POST /v1/functions/{functionId}/deploy`. `httpEnabled` is applied to the deployed function immediately, so turning the public endpoint on or off does not require a redeploy. # Update the linked repository Source: https://docs.zavu.dev/api-reference/functions/update-the-linked-repository /openapi.json patch /v1/functions/{functionId}/git-link Change the branch, the root directory, or whether pushes deploy. Pass at least one field. `rootDir: null` clears the subdirectory. # Get broadcast Source: https://docs.zavu.dev/api-reference/get-broadcast /openapi.json get /v1/broadcasts/{broadcastId} # Get broadcast progress Source: https://docs.zavu.dev/api-reference/get-broadcast-progress /openapi.json get /v1/broadcasts/{broadcastId}/progress Get real-time progress of a broadcast including delivery counts and estimated completion time. # Get contact Source: https://docs.zavu.dev/api-reference/get-contact /openapi.json get /v1/contacts/{contactId} # Get contact by phone number Source: https://docs.zavu.dev/api-reference/get-contact-by-phone-number /openapi.json get /v1/contacts/phone/{phoneNumber} # Get invitation Source: https://docs.zavu.dev/api-reference/get-invitation /openapi.json get /v1/invitations/{invitationId} # Get message by ID Source: https://docs.zavu.dev/api-reference/get-message-by-id /openapi.json get /v1/messages/{messageId} # Get phone number Source: https://docs.zavu.dev/api-reference/get-phone-number /openapi.json get /v1/phone-numbers/{phoneNumberId} Get details of a specific phone number. # Get project webhook Source: https://docs.zavu.dev/api-reference/get-project-webhook /openapi.json get /v1/invitations/webhook The project webhook for the project behind the API key. `webhook` is null when none is configured. The secret is never returned here. # Get regulatory requirements Source: https://docs.zavu.dev/api-reference/get-regulatory-requirements /openapi.json get /v1/phone-numbers/requirements Get the regulatory information needed to buy a phone number, for one specific number or for a country and number type. Prefer `phoneNumber`: the response is then exactly the list the purchase of that number validates against. Pass each `requirementTypes[].id` back as `requirementType` in `regulatoryRequirements` on `POST /v1/phone-numbers`. For `phoneNumber`, the requirements of that exact number are returned. When they cannot be resolved for the number itself, the list for its country and `type` is returned instead, and the purchase uses the same list. An empty `items` array means the number needs no regulatory information. If the requirements cannot be retrieved at all, the response is `502 requirements_unavailable`, never an empty list. URL-encode the `+` of `phoneNumber` as `%2B`. An unencoded `+` is also accepted. # Get sender Source: https://docs.zavu.dev/api-reference/get-sender /openapi.json get /v1/senders/{senderId} # Get template Source: https://docs.zavu.dev/api-reference/get-template /openapi.json get /v1/templates/{templateId} # Get WhatsApp Business profile Source: https://docs.zavu.dev/api-reference/get-whatsapp-business-profile /openapi.json get /v1/senders/{senderId}/profile Get the WhatsApp Business profile for a sender. The sender must have a WhatsApp Business Account connected. # Introspect phone number Source: https://docs.zavu.dev/api-reference/introspect-phone-number /openapi.json post /v1/introspect/phone Validate a phone number and check if a WhatsApp conversation window is open. # List broadcast contacts Source: https://docs.zavu.dev/api-reference/list-broadcast-contacts /openapi.json get /v1/broadcasts/{broadcastId}/contacts List contacts in a broadcast with optional status filter. # List broadcasts Source: https://docs.zavu.dev/api-reference/list-broadcasts /openapi.json get /v1/broadcasts List broadcasts for this project. # List contacts Source: https://docs.zavu.dev/api-reference/list-contacts /openapi.json get /v1/contacts List contacts with their communication channels. # List invitations Source: https://docs.zavu.dev/api-reference/list-invitations /openapi.json get /v1/invitations List partner invitations for this project. # List message attachments Source: https://docs.zavu.dev/api-reference/list-message-attachments /openapi.json get /v1/messages/{messageId}/attachments List the stored file attachments for an email message and get a short-lived signed `downloadUrl` for each. Works for both inbound emails (received via `message.inbound`) and outbound emails you sent with attachments. Messages without stored attachments (including SMS, WhatsApp, and other channels) return an empty list. Each `downloadUrl` is generated fresh per request and expires — fetch the file promptly and do not cache the URL. # List messages Source: https://docs.zavu.dev/api-reference/list-messages /openapi.json get /v1/messages List messages previously sent by this project. # List phone numbers Source: https://docs.zavu.dev/api-reference/list-phone-numbers /openapi.json get /v1/phone-numbers List all phone numbers owned by this project. # List senders Source: https://docs.zavu.dev/api-reference/list-senders /openapi.json get /v1/senders # List templates Source: https://docs.zavu.dev/api-reference/list-templates /openapi.json get /v1/templates List WhatsApp message templates for this project. # Create a collection Source: https://docs.zavu.dev/api-reference/memory/create-a-collection /openapi.json post /v1/memory/collections Declare a collection of JSON documents addressed by key. This is the counterpart to facts: `POST /v1/memory` recalls by meaning, a collection recalls by key. **Create it explicitly when you need `query`.** Writing to an unknown collection creates it implicitly, but with no indexes — and indexes cannot be added afterwards. If you will ever look items up by a field rather than by key, declare that field here, before the first write. A collection is defined once for the project. Its ITEMS are per-scope, so the same collection holds a separate set of documents for the project, for each contact, and for each conversation. # Delete a collection Source: https://docs.zavu.dev/api-reference/memory/delete-a-collection /openapi.json delete /v1/memory/collections/{collection} Delete the collection and every item it holds, **in every scope** — the per-contact and per-conversation copies included. Returns immediately; the purge drains in batches and writes are refused while `status` is `deleting`. # Delete an item Source: https://docs.zavu.dev/api-reference/memory/delete-an-item /openapi.json delete /v1/memory/collections/{collection}/items/{key} Idempotent: deleting a key that is not there, or a key of a collection that does not exist, still answers `204`. Deletes are never refused for quota reasons. # Forget a fact Source: https://docs.zavu.dev/api-reference/memory/forget-a-fact /openapi.json delete /v1/memory/{memoryId} Delete one fact. Idempotent: deleting a fact that is not there, or no longer there, still answers `204`. Deletes are never refused for quota reasons. # Forget an entire scope Source: https://docs.zavu.dev/api-reference/memory/forget-an-entire-scope /openapi.json delete /v1/memory Delete every fact in one scope. Returns immediately; the purge runs in batches in the background, so a list right afterwards can still show rows. This does not touch collections — `DELETE /v1/memory/collections/{collection}` does that. # Get a collection Source: https://docs.zavu.dev/api-reference/memory/get-a-collection /openapi.json get /v1/memory/collections/{collection} The collection's definition: its indexes, default TTL and status. Collections are project-level, so this takes no scope. # Get a fact Source: https://docs.zavu.dev/api-reference/memory/get-a-fact /openapi.json get /v1/memory/{memoryId} # Get an item Source: https://docs.zavu.dev/api-reference/memory/get-an-item /openapi.json get /v1/memory/collections/{collection}/items/{key} # Get Memory usage Source: https://docs.zavu.dev/api-reference/memory/get-memory-usage /openapi.json get /v1/memory/usage This month's Memory consumption for the project, with the plan quotas it is measured against. Read `capReached` to know whether writes are currently being refused. # List collections Source: https://docs.zavu.dev/api-reference/memory/list-collections /openapi.json get /v1/memory/collections # List facts Source: https://docs.zavu.dev/api-reference/memory/list-facts /openapi.json get /v1/memory Every fact in a scope, newest first. This is the bookkeeping view — to actually recall something, use `POST /v1/memory/search`, which ranks by meaning. # List items Source: https://docs.zavu.dev/api-reference/memory/list-items /openapi.json get /v1/memory/collections/{collection}/items Items in one scope of a collection, by key in lexicographic order. Narrow with `prefix` — key your documents so a prefix is meaningful (`order:2026-08:...`) and this becomes a range scan rather than a filter. # Query items by field Source: https://docs.zavu.dev/api-reference/memory/query-items-by-field /openapi.json post /v1/memory/collections/{collection}/query Find items by an indexed field instead of by key, within one scope. The field must have been declared in `indexes` when the collection was created. Querying any other field returns `400 field_not_indexed` — including every field of a collection that was auto-created by its first write, which has no indexes at all. # Recall facts by meaning Source: https://docs.zavu.dev/api-reference/memory/recall-facts-by-meaning /openapi.json post /v1/memory/search Embed the query and return the closest facts in the scope, ranked by cosine similarity. **Scope goes in the body**, like `POST /v1/memory`: `scope` plus the matching id. Without `minScore`, a scope always answers with its `limit` closest facts however distant they are — a search that found nothing relevant is indistinguishable from one that found the right thing. Set `minScore` when your code branches on "did I know this?". # Remember a fact Source: https://docs.zavu.dev/api-reference/memory/remember-a-fact /openapi.json post /v1/memory Store one fact so the agent can recall it later by meaning. The text is embedded on write; retrieval is semantic, not keyword. **Scope goes in the body here.** Pass `scope` plus the matching `contactId` / `conversationId`. Omit it for the project scope. Every read endpoint takes the scope as a `?scope=` query parameter instead — see the `scope` parameter on `GET /v1/memory`. **One fact per call.** A paragraph carrying five unrelated facts is embedded as an average of all five and retrieved well by none of them. # Update a collection Source: https://docs.zavu.dev/api-reference/memory/update-a-collection /openapi.json patch /v1/memory/collections/{collection} Only `defaultTtlSeconds` can change. Indexes are fixed at creation: changing them would require rewriting every item in the collection. # Write an item Source: https://docs.zavu.dev/api-reference/memory/write-an-item /openapi.json put /v1/memory/collections/{collection}/items/{key} Store a JSON document under a key. Creates the collection if it does not exist — with **no indexes**, which `query` then cannot use. Declare indexes with `POST /v1/memory/collections` first if you need them. This replaces the whole document; it does not merge. Pass `If-Match` with the revision from a previous read to make the write conditional. # Write items in bulk Source: https://docs.zavu.dev/api-reference/memory/write-items-in-bulk /openapi.json post /v1/memory/collections/{collection}/items/batch Up to 25 sets and deletes in one call, in one scope. **Not a transaction.** Each operation succeeds or fails on its own and is reported separately; the call returns `200` even when some failed. Check every entry's `ok` — treating a 200 as "all 25 landed" is the mistake this endpoint invites. # Merge contacts Source: https://docs.zavu.dev/api-reference/merge-contacts /openapi.json post /v1/contacts/{contactId}/merge Merge a source contact into this contact. All channels from the source contact will be moved to the target contact, and the source contact will be marked as merged. # Purchase a phone number Source: https://docs.zavu.dev/api-reference/purchase-a-phone-number /openapi.json post /v1/phone-numbers Purchase an available phone number. Requires a paid plan: the Free plan cannot purchase phone numbers and receives `402` with code `paid_plan_required`. **The included number.** A paid plan includes one number at no charge, once per account: it must be a US or Canadian number (a +1 number) costing $20 a month or less. `isFreeEligible` in `GET /v1/phone-numbers/available` marks the numbers that qualify. Claiming it spends the benefit for good, across every team the account owner owns, so releasing that number does not make another one free. **Numbers with regulatory requirements.** Which numbers need regulatory information is decided per number, not by a fixed country list. The purchase looks the requirements up for the exact number before charging anything: 1. `GET /v1/phone-numbers/requirements?phoneNumber=...`. If `items` is empty, buy normally. 2. Create what it asks for: addresses with `POST /v1/addresses`, documents with `POST /v1/documents`. 3. Purchase with `type` and `regulatoryRequirements`. The number is bought and billed at once with `regulatoryStatus: pending_review`. 4. Poll `GET /v1/phone-numbers/{phoneNumberId}` until `regulatoryStatus` is `approved`. Assign it to a sender before or after approval; it starts carrying messages once approved. **Reuse.** Information you submitted is kept for your project, per country and `type`, and a later purchase there may omit `regulatoryRequirements`. Reuse only happens when what is kept still covers every requirement of the new number and every address and document in it belongs to the project. Otherwise, or when nothing is kept, the purchase returns `400 regulatory_compliance_required` with the missing requirements in `details`. Invalid values (a missing, unknown or repeated requirement id, an address or document from another project, or one rejected in review) return `400 invalid_request`. If an address or document cannot be registered for review, the purchase returns `400 invalid_request` naming the requirement. If the requirements cannot be looked up, the purchase returns `502 requirements_unavailable`, except for US and Canadian numbers, which are sold as numbers without requirements. None of these errors charge anything. # Regenerate webhook secret Source: https://docs.zavu.dev/api-reference/regenerate-webhook-secret /openapi.json post /v1/senders/{senderId}/webhook/secret Regenerate the webhook secret for a sender. The old secret will be invalidated immediately. # Create address Source: https://docs.zavu.dev/api-reference/regulatory/create-address /openapi.json post /v1/addresses Create a regulatory address, to use as the value of an `address` requirement when buying a phone number. It is registered for review when it is created, with status `pending`. # Create document Source: https://docs.zavu.dev/api-reference/regulatory/create-document /openapi.json post /v1/documents Create a regulatory document record after uploading the file. Use the upload-url endpoint first to get an upload URL. # Delete address Source: https://docs.zavu.dev/api-reference/regulatory/delete-address /openapi.json delete /v1/addresses/{addressId} Delete a regulatory address from this project. Any address can be deleted, whatever its status. Phone numbers already purchased with it are not affected, and neither is information already submitted for later purchases in its country. # Delete document Source: https://docs.zavu.dev/api-reference/regulatory/delete-document /openapi.json delete /v1/documents/{documentId} Delete a regulatory document. Cannot delete verified documents. # Get address Source: https://docs.zavu.dev/api-reference/regulatory/get-address /openapi.json get /v1/addresses/{addressId} Get a specific regulatory address. # Get document Source: https://docs.zavu.dev/api-reference/regulatory/get-document /openapi.json get /v1/documents/{documentId} Get a specific regulatory document. # Get document upload URL Source: https://docs.zavu.dev/api-reference/regulatory/get-document-upload-url /openapi.json post /v1/documents/upload-url Get a presigned URL to upload a document file. After uploading, use the storageId to create the document record. # List addresses Source: https://docs.zavu.dev/api-reference/regulatory/list-addresses /openapi.json get /v1/addresses List regulatory addresses for this project. # List documents Source: https://docs.zavu.dev/api-reference/regulatory/list-documents /openapi.json get /v1/documents List regulatory documents for this project. # Release phone number Source: https://docs.zavu.dev/api-reference/release-phone-number /openapi.json delete /v1/phone-numbers/{phoneNumberId} Release a phone number. The phone number must not be assigned to a sender. # Remove channel from contact Source: https://docs.zavu.dev/api-reference/remove-channel-from-contact /openapi.json delete /v1/contacts/{contactId}/channels/{channelId} Remove a communication channel from a contact. Cannot remove the last channel. # Remove contact from broadcast Source: https://docs.zavu.dev/api-reference/remove-contact-from-broadcast /openapi.json delete /v1/broadcasts/{broadcastId}/contacts/{contactId} Remove a contact from a broadcast in draft status. # Remove project webhook Source: https://docs.zavu.dev/api-reference/remove-project-webhook /openapi.json delete /v1/invitations/webhook Remove the project webhook and its secret. Project events stop being delivered. # Reschedule broadcast Source: https://docs.zavu.dev/api-reference/reschedule-broadcast /openapi.json patch /v1/broadcasts/{broadcastId}/schedule Update the scheduled time for a broadcast. The broadcast must be in scheduled status. # Retry broadcast review Source: https://docs.zavu.dev/api-reference/retry-broadcast-review /openapi.json post /v1/broadcasts/{broadcastId}/retry-review Resubmit a rejected broadcast for AI review after editing content. Maximum 3 review attempts allowed per broadcast. # Search available phone numbers Source: https://docs.zavu.dev/api-reference/search-available-phone-numbers /openapi.json get /v1/phone-numbers/available Search for available phone numbers to purchase by country and type. # Send a message Source: https://docs.zavu.dev/api-reference/send-a-message /openapi.json post /v1/messages Send a message to a recipient via SMS or WhatsApp. **Channel selection:** - If `channel` is omitted and `messageType` is `text`, defaults to SMS - If `messageType` is anything other than `text`, WhatsApp is used automatically **WhatsApp 24-hour window:** - Free-form messages (non-template) require an open 24h window - Window opens when the user messages you first - Use template messages to initiate conversations outside the window **Plan allowances and email billing:** - WhatsApp, Telegram, Instagram and Messenger share an allowance of 2,000 messages per month on Free. **It counts messages in both directions**: a message a contact sends you consumes one unit exactly as a message you send them does, so a project that has sent 300 and received 1,700 has used the whole allowance. Messages you send from the WhatsApp Business App on your own phone under coexistence are mirrored into your inbox but never counted, and neither are failed sends. **Pay as you go:** a Free team that has added funds to its balance is not stopped at the allowance. Past it, each started block of 1,000 messages is prepaid from the balance at $4 ($0.004 a message, either direction); only when the balance cannot cover the next block does the rule below apply. Otherwise, over the allowance, sends return 429 with code `a2p_limit_exceeded` and upgrade details, **and inbound messages on those channels are held**: kept, but not shown in the inbox and no `message.inbound` webhook until the team adds funds or moves to a paid plan (or, for a pay-as-you-go team stopped by its spend cap, raises the cap). Held messages are then delivered in arrival order with their original `providerTimestamp`, counted like any other, and an AI agent does not reply to them. Up to 1,000 are held per team, each for 30 days; past either limit they are refused and not delivered later. The month resetting does not release them. The counter resets on the 1st of each month. Paid plans have no message caps - Email is billed from your prepaid balance in 1,000-message blocks: $0.40 per 1,000 transactional emails, $0.80 per 1,000 marketing (broadcast) emails. A block is charged when your monthly count crosses each 1,000 boundary, and at zero balance email sends return 402 with code `insufficient_balance`. Free teams start with $2 of credit and additionally cap at 3,000 emails/month (lifted once the team has added funds: past it, email keeps billing from the balance in the same blocks) and 100/day. Email broadcasts (marketing email) need a paid plan: Free cannot send them, see `POST /v1/broadcasts/{broadcastId}/send`. Teams on earlier plans keep their original email quotas instead - SMS and voice are billed per message from your balance on every plan **Daily limits:** - An account sends on every channel from its first minute, to any destination. Verification is not a permission to send: identity verification and business verification (KYB) raise the ceilings below. That includes `sms_oneway`: it sends without verification, within the SMS ceiling, and every message passes the risk review below. KYB is still required to register a 10DLC brand and campaign, which every US and Canadian (+1) SMS destination needs — a carrier rule, answered separately with `403 ten_dlc_required` - Daily ceilings apply per channel group and rise with verification. An account that has verified nothing: 25/day across `sms` + `sms_oneway`, 5/day for `voice`, 100/day across WhatsApp, Telegram, Instagram and Messenger combined. Past that floor: 200/day for SMS, or 10,000/day once identity or business verification is approved (or a higher limit agreed for your account); 50/day voice and 250/day conversational on Free. **Paid plans have no voice or conversational daily ceiling**, and neither does a Free team that has added funds on conversational (voice keeps 50/day). Over a ceiling, sends return `429` with code `daily_limit_exceeded` and `details.limit`; the count resets at 00:00 UTC - The daily ceiling never reduces the monthly allowance: 100/day on the conversational group still reaches the 2,000 monthly A2P messages Free includes - Email: a sender with a verified domain sends from day one, within the plan quota (100/day and 3,000/month on Free). Over the daily quota it returns `429` with code `daily_limit_exceeded` - Every plan, paid ones included, also has a rolling email ceiling by account age: 200 per hour and 1,000 per 24 hours for the first 7 days, 1,000 per hour and 10,000 per 24 hours until day 30, then 5,000 per hour and 50,000 per 24 hours. Past it the request is accepted and the message then fails with `errorCode` `EMAIL_RATE_LIMIT_HOURLY` or `EMAIL_RATE_LIMIT_DAILY`, visible on `GET /v1/messages/{messageId}` and the `message.failed` webhook. It does not reset at 00:00 UTC: it counts the last hour and the last 24 hours - Full reference: https://docs.zavu.dev/concepts/sending-limits **Risk review:** Every outbound `sms`, `sms_oneway`, `email` and `voice` message is read before it is sent — the content, and how this account has been sending. What is checked is the message, not who you are. - A message can be **held** for a short review. It stays `queued` while it waits: no new status exists for this, and `MessageStatus` is unchanged. When it is approved it sends normally. - A message that is not approved moves to `failed` and fires `message.failed`. `errorCode` says which happened: `RISK_REJECTED` (a reviewer refused it), `RISK_REVIEW_EXPIRED` (the review window closed first — it is a couple of hours, because a code that arrives late is worse than one that does not arrive), or `RISK_BLOCKED` (refused outright, without a hold). An SMS that fails this way is not charged; the prepaid amount is returned. - A call is never held. `POST /v1/calls` fails a call the review stops rather than placing it hours late. - A message whose content cannot be read — the check is briefly unavailable — is held rather than sent. An account with an approved business verification is unaffected, and so is one that has verified something, already sends real traffic, and has a clean recent record. - Repeated refusals suspend an account's sending. While it is suspended every send is refused with `403` and code `sending_suspended`, `details.dashboardUrl` points at support, and a message already queued fails with `errorCode` `SENDING_SUSPENDED`. - A broadcast is read once, on the broadcast itself, rather than per recipient — see `POST /v1/broadcasts/{broadcastId}/send`. **Email recipient pre-flight:** Email messages are validated automatically before dispatch. Sends that would be a guaranteed hard bounce are failed instead of sent, protecting your bounce rate: the message transitions to `failed` (visible via `GET /v1/messages/{messageId}` and the `message.failed` webhook) with `errorCode` set to `EMAIL_INVALID_RECIPIENT` (malformed address), `EMAIL_DOMAIN_NOT_FOUND` (recipient domain has no MX or A records), or `EMAIL_RECIPIENT_SUPPRESSED` (address is on your suppression list after a previous bounce or complaint). Advisory signals (role addresses, disposable domains) do not block sends — check them beforehand with `POST /v1/introspect/email`. # Send broadcast Source: https://docs.zavu.dev/api-reference/send-broadcast /openapi.json post /v1/broadcasts/{broadcastId}/send Start sending the broadcast immediately or schedule for later. **Sending a broadcast needs no account verification.** Identity and business verification raise daily ceilings; neither is a permission to broadcast, on any channel. What stands in front of a broadcast is the content review below, and it applies to every send path — dashboard, API and CLI. Drafts can be created, edited and kept freely. **Email broadcasts need a paid plan.** On Free, sending or scheduling a broadcast on the `email` channel is refused with `402` and code `paid_plan_required`; drafts can still be created and edited. A `smart` broadcast is not refused: its email recipients fail individually with `errorCode` `PAID_PLAN_REQUIRED` and the others are sent. Teams created before August 1, 2026 keep their earlier terms. **Daily ceilings apply per recipient.** Each message a broadcast sends counts against the channel's daily ceiling (see `POST /v1/messages`). Once the ceiling is reached, the remaining recipients are marked `failed` with `errorCode` `DAILY_LIMIT_EXCEEDED`; they are not retried the next day. **Review depends on the channel, and cannot be bypassed.** A draft is submitted to automated content review here; it does not go straight out. A WhatsApp broadcast built on a Meta-approved template skips review (Meta already vetted the content) and begins sending. An email broadcast sends as soon as the review passes it, unless the review asks for a person. Every other channel moves to `pending_admin_review` and waits for a person. A broadcast the review refuses lands on `rejected`: use PATCH to edit the content then call POST /retry-review, or escalate it for a manual review. A broadcast is read once, on its own text, rather than per recipient. While an account's sending is suspended its recipients fail individually with `errorCode` `SENDING_SUSPENDED`; see `POST /v1/messages`. Calling this on a broadcast that is already `approved` or `scheduled` sends or reschedules it directly, since it has already been reviewed. Reserves the estimated cost from your balance. # Send reaction to message Source: https://docs.zavu.dev/api-reference/send-reaction-to-message /openapi.json post /v1/messages/{messageId}/reactions Send an emoji reaction to an existing WhatsApp message. Reactions are only supported for WhatsApp messages. # Activate a sender channel Source: https://docs.zavu.dev/api-reference/senders/activate-a-sender-channel /openapi.json post /v1/senders/{senderId}/channels/{channel}/activate Switch on the account connected to this sender for one channel, so it sends and receives. This is the same switch as **Activate** on the dashboard's Accounts screen, with the same price and the same limits. A newly connected account that your plan already includes (nothing charged now, nothing added to the monthly bill) is switched on by itself as soon as it can carry messages. One that would cost money starts inactive: every send on it is refused and it is not listed in the sender's `channels` until you activate it here or from Accounts, after seeing the price. A connection you switched off stays off. **Billing.** Activating is billed as a channel connection. On a paid plan that is one full month charged from the balance at once, and the same amount every month while the connection stays active; the response states both as `chargedCents` and `monthlyCents`. The first connections a plan includes cost nothing, and so does re-activating a connection whose current month is already paid. A WhatsApp Business Account is never charged from the balance for its month: one beyond the ones the plan includes is billed on the subscription instead. The exception is a WhatsApp account switched off for non-payment, which settles its past-due fee from the balance when re-activated. Calling this on a channel that is already active changes nothing and charges nothing. Not available with test-mode keys. # Connect Telegram Source: https://docs.zavu.dev/api-reference/senders/connect-telegram /openapi.json post /v1/senders/{senderId}/telegram Connect a Telegram bot to a sender. Provide the bot token from @BotFather; Zavu validates it, registers the webhook, and routes the sender's Telegram messages through it. # Deactivate a sender channel Source: https://docs.zavu.dev/api-reference/senders/deactivate-a-sender-channel /openapi.json post /v1/senders/{senderId}/channels/{channel}/deactivate Switch off the account connected to this sender for one channel. Sends on it are refused and it leaves the sender's `channels`. The account stays connected, so activating it again needs no reconnection. The month already paid is not refunded, and re-activating before it ends charges nothing. Calling this on a channel that is already inactive changes nothing. Not available with test-mode keys. # Disconnect Telegram Source: https://docs.zavu.dev/api-reference/senders/disconnect-telegram /openapi.json delete /v1/senders/{senderId}/telegram Disconnect Telegram from a sender and remove the webhook. # Set channel as primary Source: https://docs.zavu.dev/api-reference/set-channel-as-primary /openapi.json post /v1/contacts/{contactId}/channels/{channelId}/primary Set a channel as the primary channel for its type. # Show typing indicator Source: https://docs.zavu.dev/api-reference/show-typing-indicator /openapi.json post /v1/messages/{messageId}/typing Mark an inbound WhatsApp message as read and display a typing indicator to the user while you prepare a response. The indicator is automatically dismissed when you send a reply, or after 25 seconds — whichever comes first. Only valid for inbound WhatsApp messages. Use this when a reply will take more than a couple of seconds (LLM agent, tool call, lookup) to improve the recipient's experience. # Create sub-account Source: https://docs.zavu.dev/api-reference/sub-accounts/create-sub-account /openapi.json post /v1/sub-accounts Create a new sub-account (project) with its own API key. All charges are billed to the parent team's balance. Use creditLimit to set a spending cap. The sub-account's API key is returned only in the creation response. Requires a parent project API key; sub-account API keys receive HTTP 403. # Create sub-account API key Source: https://docs.zavu.dev/api-reference/sub-accounts/create-sub-account-api-key /openapi.json post /v1/sub-accounts/{id}/api-keys Create sub-account API key. Requires a parent project API key; sub-account API keys receive HTTP 403. # Deactivate sub-account Source: https://docs.zavu.dev/api-reference/sub-accounts/deactivate-sub-account /openapi.json delete /v1/sub-accounts/{id} Deactivate a sub-account. Remaining balance is returned to the parent team and all API keys are revoked. Requires a parent project API key; sub-account API keys receive HTTP 403. # Get sub-account Source: https://docs.zavu.dev/api-reference/sub-accounts/get-sub-account /openapi.json get /v1/sub-accounts/{id} Get sub-account. Requires a parent project API key; sub-account API keys receive HTTP 403. # Get sub-account spending Source: https://docs.zavu.dev/api-reference/sub-accounts/get-sub-account-spending /openapi.json get /v1/sub-accounts/{id}/balance Get spending information for a sub-account. Returns the parent team's balance, the sub-account's total spending, and its credit limit (spending cap). Requires a parent project API key; sub-account API keys receive HTTP 403. # List sub-account API keys Source: https://docs.zavu.dev/api-reference/sub-accounts/list-sub-account-api-keys /openapi.json get /v1/sub-accounts/{id}/api-keys List sub-account API keys. Requires a parent project API key; sub-account API keys receive HTTP 403. # List sub-accounts Source: https://docs.zavu.dev/api-reference/sub-accounts/list-sub-accounts /openapi.json get /v1/sub-accounts List sub-accounts for this team. Requires a parent project API key; sub-account API keys receive HTTP 403. # Revoke sub-account API key Source: https://docs.zavu.dev/api-reference/sub-accounts/revoke-sub-account-api-key /openapi.json delete /v1/sub-accounts/{id}/api-keys/{keyId} Revoke sub-account API key. Requires a parent project API key; sub-account API keys receive HTTP 403. # Update sub-account Source: https://docs.zavu.dev/api-reference/sub-accounts/update-sub-account /openapi.json patch /v1/sub-accounts/{id} Update sub-account. Requires a parent project API key; sub-account API keys receive HTTP 403. # Submit template for approval Source: https://docs.zavu.dev/api-reference/submit-template-for-approval /openapi.json post /v1/templates/{templateId}/submit Submit a WhatsApp template to Meta for approval. The template must be in draft status and associated with a sender that has a WhatsApp Business Account configured. # Sync templates from WhatsApp Source: https://docs.zavu.dev/api-reference/sync-templates-from-whatsapp /openapi.json post /v1/templates/sync Reconcile this project's templates against WhatsApp. Three things happen per connected WhatsApp Business Account: templates that exist on Meta but not in Zavu are imported (or linked to an existing template with the same name), the approval status of the templates Zavu already knows about is refreshed from Meta, and their **category** is refreshed from Meta. The category matters because it is what each message is billed under, and Meta reassigns it on its own — commonly `UTILITY` to `MARKETING`, on a template that is already approved and whose status therefore never moves. A template whose category changed but whose status did not is still counted in `updated`. This is the way to repair templates whose category drifted before you started listening for `template.status_changed`. This is what to call when a template was created outside Zavu — in Meta Business Manager, or by another tool — or when a `template.status_changed` webhook was missed and a template is stuck in `pending`. Status changes normally arrive by webhook; this endpoint is the recovery path and the only path for a template Zavu never created. Templates that Meta reports as rejected or disabled are not imported; they are counted in `skipped`. Existing local templates are matched first by Meta template ID, then by name. By default every sender in the project with a WhatsApp Business Account is synced. Pass `senderId` to sync only that sender's account. The call is synchronous — it waits for Meta and returns what changed — so it can take a few seconds per account. A failure on one account does not fail the request: it is reported in `errors` and the remaining accounts are still synced. # Update broadcast Source: https://docs.zavu.dev/api-reference/update-broadcast /openapi.json patch /v1/broadcasts/{broadcastId} Update a broadcast in draft status. # Update channel Source: https://docs.zavu.dev/api-reference/update-channel /openapi.json patch /v1/contacts/{contactId}/channels/{channelId} Update a contact's channel properties. # Update contact Source: https://docs.zavu.dev/api-reference/update-contact /openapi.json patch /v1/contacts/{contactId} # Update phone number Source: https://docs.zavu.dev/api-reference/update-phone-number /openapi.json patch /v1/phone-numbers/{phoneNumberId} Update a phone number's name or sender assignment. # Update project webhook Source: https://docs.zavu.dev/api-reference/update-project-webhook /openapi.json patch /v1/invitations/webhook Change the URL, the events, or whether the webhook is active. The webhook must already exist. # Update sender Source: https://docs.zavu.dev/api-reference/update-sender /openapi.json patch /v1/senders/{senderId} # Update WhatsApp Business profile Source: https://docs.zavu.dev/api-reference/update-whatsapp-business-profile /openapi.json patch /v1/senders/{senderId}/profile Update the WhatsApp Business profile for a sender. The sender must have a WhatsApp Business Account connected. # Upload WhatsApp Business profile picture Source: https://docs.zavu.dev/api-reference/upload-whatsapp-business-profile-picture /openapi.json post /v1/senders/{senderId}/profile/picture Upload a new profile picture for the WhatsApp Business profile. The image will be uploaded to Meta and set as the profile picture. # Escalate a rejected URL Source: https://docs.zavu.dev/api-reference/url-verification/escalate-a-rejected-url /openapi.json post /v1/urls/{urlId}/escalate Request manual review of a rejected URL. Only URLs in 'rejected' status can be escalated; the status then moves to 'escalated'. # Get URL details Source: https://docs.zavu.dev/api-reference/url-verification/get-url-details /openapi.json get /v1/urls/{urlId} Get details of a specific verified URL. # List verified URLs Source: https://docs.zavu.dev/api-reference/url-verification/list-verified-urls /openapi.json get /v1/urls List URLs that have been verified for this project. # Submit URL for verification Source: https://docs.zavu.dev/api-reference/url-verification/submit-url-for-verification /openapi.json post /v1/urls Submit a URL for verification. URLs are automatically checked against Google Web Risk API. Safe URLs are auto-approved, malicious URLs are blocked. URL shorteners (bit.ly, t.co, etc.) are always blocked. **Important:** SMS, one-way SMS and voice messages containing URLs require those URLs to be verified before the message can be sent; a URL that is not yet known is checked when the message is sent. Email, WhatsApp, Telegram, Instagram and Messenger messages are not checked. This endpoint allows pre-verification of URLs. # Validate email addresses Source: https://docs.zavu.dev/api-reference/validate-email-addresses /openapi.json post /v1/introspect/email Heuristic email validation to run before sending: catches invalid syntax, dead domains (no MX/A records), disposable inboxes, role-based addresses (info@, contacto@, sales@), and addresses already on your project's suppression list. Use it to clean a list before a broadcast and keep your bounce rate low. No mailbox-level (SMTP) probe is performed, so a `deliverable` verdict is not a delivery guarantee — it means no negative signal was found. Treat `risky` addresses with care and drop `undeliverable` ones. Accepts a single `email` or an `emails` batch (max 100 per request). # Get voice call Source: https://docs.zavu.dev/api-reference/voice-agents/get-voice-call /openapi.json get /v1/calls/{callId} Retrieve a single voice call, including its full transcript once the conversation has produced turns. # Hang up a voice call Source: https://docs.zavu.dev/api-reference/voice-agents/hang-up-a-voice-call /openapi.json post /v1/calls/{callId}/hangup End an active voice call. The call must still be queued, ringing or in progress; a call that already ended answers `409`. Not available with test-mode API keys. # List voice calls Source: https://docs.zavu.dev/api-reference/voice-agents/list-voice-calls /openapi.json get /v1/calls List voice calls for this project, most recent first. Transcripts are omitted from the list; fetch a single call to get its transcript. # Place a voice call Source: https://docs.zavu.dev/api-reference/voice-agents/place-a-voice-call /openapi.json post /v1/calls Place an outbound voice call answered by the voice agent configured on the sender. Zavu dials the recipient and runs the conversation through its managed voice pipeline (speech recognition, the agent's LLM, and speech synthesis, with real-time interruption handling). **Requirements:** - An account may call any destination from its first minute, within the daily ceiling: 5 calls a day for an account that has verified nothing, 50 a day on Free once it has verified its identity, added a payment method, settled a deposit or subscribed. Paid plans have no daily call ceiling. Over it, `429` with code `daily_limit_exceeded`. Full reference: https://docs.zavu.dev/concepts/sending-limits - The call is read by Zavu's automated risk review before it is dialed, the way a message is (see `POST /v1/messages`). A call is never held for a person: one the review stops is failed rather than placed late. Repeated refusals suspend the account's calling, answered with `403` and code `sending_suspended`. - The sender's agent must have `voice.enabled` set to `true`. - Not available with test-mode API keys. **Billing:** Voice calls are billed per minute of connected time plus telephony, deducted from your prepaid balance. A short-duration estimate is reserved when the call is placed; you are charged for the actual duration when the call ends. # Get WhatsApp sync status Source: https://docs.zavu.dev/api-reference/whatsapp-sync/get-whatsapp-sync-status /openapi.json get /v1/senders/{senderId}/whatsapp-sync Get the current sync status for a sender's WhatsApp coexistence account. Only available for senders connected in coexistence mode (WhatsApp Business App + Cloud API). # Start contacts sync Source: https://docs.zavu.dev/api-reference/whatsapp-sync/start-contacts-sync /openapi.json post /v1/senders/{senderId}/whatsapp-sync/contacts Initiate contact names sync from the WhatsApp Business App. This imports contact names stored in the app to Zavu. Only available for coexistence accounts with active status. # Start history sync Source: https://docs.zavu.dev/api-reference/whatsapp-sync/start-history-sync /openapi.json post /v1/senders/{senderId}/whatsapp-sync/history Initiate message history sync from the WhatsApp Business App. This sends a request to the account owner to approve sharing their conversation history. Only available for coexistence accounts with active status. # Addresses and channels Source: https://docs.zavu.dev/api-v2/addresses How API v2 names where a message leaves from: one entry per address and transport, how from is resolved, and how to connect, switch and remove channels. ## One entry per address and transport v2 has no sender object. `GET /v2/channels` lists every place the project can send from, one entry per (address, type): | Field | Meaning | | - | - | | `address` | What the recipient sees: an E.164 number, an email address, `@botname`, `@username`, a Facebook Page id, or `sms_oneway`. | | `type` | The transport: `sms`, `sms_oneway`, `whatsapp`, `email`, `telegram`, `instagram`, `messenger`, `voice`. | | `active` | Whether it can send right now. | | `v1SenderId` | Read-only. The v1 sender behind the entry, for code that still calls v1. | A number that carries WhatsApp and SMS is two entries with the same address. `GET /v2/channels/{address}` returns the entries of one address. URL-encode the address: `+` is `%2B`. Numbers match in any common formatting. Entries with the same `v1SenderId` belong together: they are one channel group, the same thing v1 calls a sender. You rarely need to think about groups. They matter in two places: an agent connected to an address answers on every transport of its group, and two groups can share an address. ## How `from` is resolved On `POST /v2/messages` the sending address comes from, in order: 1. `conversationId`: the address the thread was last handled from. 2. `from`: one of your addresses, in any common formatting. 3. The project default, the same one v1 uses without a `Zavu-Sender` header. `from` is resolved inside your project only. An address no entry has answers `404 not_found`, with the address in `details.from`. When two groups share an address, for example a number whose WhatsApp was connected on one sender and SMS on another, `channel` picks the transport. If that still does not decide it, the answer is `409 ambiguous_from` with `details.candidates[{address, type, v1SenderId}]`. Only then does `from` accept one of those `v1SenderId` values. ## Connect a channel `POST /v2/channels` connects what needs no purchase and no Meta signup: | Body | Needs | | - | - | | `{"type": "sms_oneway"}` | Nothing. Sends within the SMS daily ceiling; recipients cannot reply. | | `{"type": "email", "address": "...", "fromName": "..."}` | A verified email domain of the project. | | `{"type": "telegram", "botToken": "..."}` | A bot token from @BotFather. | The new channel joins the project's default group when that group does not carry the transport yet. Otherwise it starts a new group, so a second email address or a second bot is sent from by naming it in `from`. For one-way SMS and email, an address that is already connected answers `200` and changes nothing. Other transports: * **Two-way SMS and voice** come with a number. `GET /v2/numbers/available`, then `POST /v2/numbers` buys the number and turns SMS on in the same call. Buying needs a paid plan. * **WhatsApp, Instagram and Messenger** connect through Meta, from **Accounts** in the dashboard. Once connected they appear in `GET /v2/channels`. ## Switch a channel on or off ```bash theme={null} curl -X POST "https://api.zavu.dev/v2/channels/%2B13125551212/whatsapp/activate" \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` `activate` and `deactivate` are the same switch as **Activate** on Accounts in the dashboard, with the same price. On a paid plan, activating a connection the plan does not include charges a month at once; the response says how much in `chargedCents` and `monthlyCents`. `sms`, `sms_oneway` and `voice` have no switch and are always on. A connection that would cost money starts inactive. A send without `channel` skips it; when it was the only way to reach the recipient, the answer is `409 no_channel` with it in `details.inactive`. A send that names it in `channel` is refused with `403 channel_inactive`. ## Remove a channel `DELETE /v2/channels/{address}/{type}` disconnects `sms_oneway`, `telegram` and `voice`. The others answer `409 disconnect_unavailable` with what to do instead: switch `whatsapp`, `instagram`, `messenger` or `email` off with `deactivate`, and manage a number's two-way SMS from **Phone Numbers** in the dashboard. # Errors Source: https://docs.zavu.dev/api-v2/errors Every API v2 error is {code, message, details} at the root. The codes you will meet, what they mean, and the details keys that tell you what to do next. ## One shape ```json theme={null} { "code": "invalid_request", "message": "Invalid request body", "details": { "errors": [{ "path": ["to"], "message": "Required" }] } } ``` There is no `{"error": {...}}` wrapper anywhere in v2. Branch on `code`; show `message` to a person. A message never carries a stack trace or a source path. ## Details keys Keys that mean the same thing on every error: | Key | Meaning | | - | - | | `errors` | On every `400 invalid_request` for a bad body or query: `[{path, message}]`, with `path` naming the field (`["to"]`, `["contacts", 2, "recipient"]`). | | `recommendation` | One sentence saying what to do next. | | `dashboardUrl` | The dashboard screen that fixes it. | | `connect` | On `409 no_channel`: what this project can connect to reach the recipient, each with the exact request, or `null` when it is connected from the dashboard. | | `from` / `address` | The address the error is about, under the name the request used. | ## Codes | Status | Code | Meaning | | - | - | - | | 400 | `invalid_request` | A field is missing or wrong. See `details.errors`. | | 400 | `invalid_json` | The body is not JSON. | | 400 | `duplicate_identifier` | Another contact already holds this identifier on this channel (`details.conflicts`). | | 400 | `invalid_bot_token` | Telegram rejected the bot token (`details.telegramSaid`). Nothing was connected. | | 401 | `unauthorized` | Missing or invalid API key. | | 402 | `insufficient_balance` | The balance cannot cover it. | | 402 | `paid_plan_required` | Needs a paid plan, for example buying a number. | | 402 | `spend_cap_reached` | The team's monthly spend cap is reached. `details` carries `capCents`, `spentCents` and `dashboardUrl`. | | 403 | `kyb_required` | One-way SMS is closed to this account until its business verification is approved. Not returned while one-way SMS needs no verification, which is the default. `details.dashboardUrl` is `/kyb`. | | 403 | `ten_dlc_required` | SMS to a +1 number needs an approved 10DLC campaign. `details.connect` lists the registration calls, `details.alternatives` the channels that need none. | | 403 | `channel_inactive` | The `channel` you named is connected but switched off. | | 403 | `sending_suspended` | Sending from this account is suspended. Contact support. | | 403 | `plan_limit_reached` | The plan has no room for another active connection. | | 404 | `not_found` | The resource, or the address, is not in this project. | | 405 | `method_not_allowed` | Known path, wrong method. `details.allowed` and the `Allow` header list the methods. | | 409 | `no_channel` | Nothing this call could use can reach the recipient. `details.connect` says what to connect. | | 409 | `ambiguous_from` | Two channel groups share the address. Pick one of `details.candidates`. | | 409 | `conflict` | A repeated `idempotencyKey`: nothing was sent again, and the original is in `details.message` (or `details.campaign`). Also a template name this project already uses. | | 409 | `invalid_status` | The campaign is in a state that does not allow this (`details.status`). | | 409 | `email_domain_missing`, `email_domain_not_verified`, `domain_setup_failed` | The email address cannot be connected yet. `details.recommendation` has the fix. | | 409 | `disconnect_unavailable` | This transport cannot be removed through the API. The message says what to do instead. | | 409 | `channel_cannot_receive` | An agent cannot answer on one-way SMS. | | 422 | `sms_compliance_blocked` | The content check refused an SMS, or could not run (the message says which). Nothing was sent or charged. `details.requiredFixes` and `details.suggestedCompliantVersion` say what to change. See [SMS](/guides/sending-messages/sms#content-check). | | 429 | `rate_limited` | The per-project request limit. Wait for `Retry-After`. | | 429 | `rate_limit_exceeded` | A narrower limit: one SMS per recipient per minute, or too many agent test runs for the project. The message says when to retry. | | 429 | `daily_limit_exceeded` | A channel's daily sending ceiling (`details.limit`). Resets at 00:00 UTC. | | 503 | `verification_unavailable` | The DNS check of an email domain could not run. Nothing changed; retry. | Sending limits, plan allowances and reviews are the same as on v1. See [Sending limits](/concepts/sending-limits). ## Retrying a 409 Every `409` is a settled answer: retrying the same request cannot change it. It carries the header `x-should-retry: false`, on v1 and v2 alike, and the SDKs honour it, so they raise the `409` as a `ConflictError` at once instead of retrying it. A repeated `idempotencyKey` is the case to handle on purpose: the original is in the error's body, and that is how a retry learns the first attempt landed. ## Wrong method A path under `/v2/messages`, `/v2/conversations`, `/v2/contacts`, `/v2/channels`, `/v2/templates`, `/v2/campaigns`, `/v2/agents`, `/v2/webhook` or `/v2/email-domains` called with a method it does not take answers `405`, not `404`: ```json theme={null} { "code": "method_not_allowed", "message": "PUT is not supported here. This path takes GET, POST.", "details": { "allowed": ["GET", "POST"] } } ``` An unknown path is `404`. # Migrate from v1 Source: https://docs.zavu.dev/api-v2/migrate-from-v1 Move from Zavu API v1 to v2 at your own pace. v1 keeps working unchanged. What maps to what, what behaves differently, and what stays on v1. ## v1 keeps working v1 is frozen, not retired. Its endpoints, payloads and webhooks keep working exactly as they do today. There is no deadline to move. You can migrate one call at a time. Both versions take the same API key and share the same data: a message, conversation, contact, campaign or agent created through one is read through the other with the same ID. Every entry of `GET /v2/channels` carries `v1SenderId`, the sender to use as `Zavu-Sender` on the v1 calls you have not moved yet. ## What maps to what | v1 | v2 | | - | - | | `Zavu-Sender` header | `from` (an address) or `conversationId` in the body | | Senders (`/v1/senders`) | Addresses: `GET /v2/channels`, `GET /v2/channels/{address}` | | `POST /v1/senders`, `PATCH /v1/senders/{id}` with `enableSmsOneway` or `emailAddress`, `POST /v1/senders/{id}/telegram` | `POST /v2/channels` | | `POST /v1/senders/{id}/channels/{channel}/activate` and `/deactivate` | `POST /v2/channels/{address}/{type}/activate` and `/deactivate` | | `DELETE /v1/senders/{id}/telegram`, `PATCH /v1/senders/{id}` with `enableSmsOneway: false` or `enableVoice: false` | `DELETE /v2/channels/{address}/{type}` | | `POST /v1/messages`, `GET /v1/messages`, `GET /v1/messages/{id}` | `POST /v2/messages`, `GET /v2/messages`, `GET /v2/messages/{id}` | | `/v1/conversations`, `?senderId=` | `/v2/conversations`, `?from=` | | `senderId` on a conversation | `from`, the address it was last handled from | | `/v1/contacts` (create, list, get, update, delete) | `/v2/contacts` | | Sender webhooks (`webhookUrl` on each sender) | One project webhook: `PUT /v2/webhook` | | `/v1/broadcasts` | `/v2/campaigns` (list, create, get, delete, send, cancel, progress) | | `POST /v1/broadcasts/{id}/contacts` | `POST /v2/campaigns/{id}/recipients` | | `GET /v1/phone-numbers/available` | `GET /v2/numbers/available` | | `POST /v1/phone-numbers`, then assign it to a sender | `POST /v2/numbers`: buys the number and turns SMS on in one call | | `/v1/templates` (list, create, get, delete) | `/v2/templates` | | `POST /v1/templates/{id}/submit` with `senderId` | `POST /v2/templates/{id}/submit` with `from` | | `/v1/agents` (list, create, get, update, delete, test) | `/v2/agents` | | `POST /v1/agents/{id}/senders`, `DELETE /v1/agents/{id}/senders/{senderId}` | `POST /v2/agents/{id}/channels` with `{address, type}`, `DELETE /v2/agents/{id}/channels/{address}/{type}` | | `/v1/email-domains` | `/v2/email-domains` | | `PATCH /v1/functions/{id}` with `httpEnabled` | `PATCH /v2/functions/{id}`, which also sets name, description, timeout, memory, architecture and pause | | No function listing | `GET /v2/functions`, `GET /v2/functions/{id}` | | Errors, some wrapped in `{"error": {...}}` | Every error is `{code, message, details}` at the root | A campaign is a v1 broadcast: its `id` works as `broadcastId` on `/v1/broadcasts/{broadcastId}`. ## Sending: from a header to an address ```bash v1 theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Zavu-Sender: kd7abc123def456" \ -H "Content-Type: application/json" \ -d '{"to": "+56912345678", "text": "Your code is 123456."}' ``` ```bash v2 theme={null} curl -X POST https://api.zavu.dev/v2/messages \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{"from": "+13125551212", "to": "+56912345678", "text": "Your code is 123456."}' ``` The `202` carries `message` and `routing`. `message.from` and `message.to` name addresses; there is no `senderId`. To reply in a thread, pass `conversationId` and drop both `from` and `to`. See [Replying in a conversation](/api-v2/overview#replying-in-a-conversation). ### With the TypeScript SDK `@zavudev/sdk` 0.59.0 calls v2 as `client.v2`. Moving a call is changing `client.x` to `client.v2.x` and the sender to an address: ```typescript v1 theme={null} await zavu.messages.send({ to: '+56912345678', text: 'Your code is 123456.', 'Zavu-Sender': 'kd7abc123def456', }); ``` ```typescript v2 theme={null} await zavu.v2.messages.send({ from: '+13125551212', to: '+56912345678', text: 'Your code is 123456.', }); ``` On v2 every id in the path is a positional argument, in path order: `zavu.v2.channels.activate('+13125551212', 'whatsapp')`. v2's types come from `@zavudev/sdk/v2`, because its `Message` and `Contact` are not v1's. See [TypeScript SDK: API v2](/sdks/typescript/v2). ## Webhooks Your v1 sender webhooks keep receiving events after you create the v2 webhook. Each event then goes to both, as separate deliveries with different event ids. 1. `PUT /v2/webhook` with your new URL and events. 2. Check that your receiver verifies and handles the v2 payload. `from`, `to` and `type` sit at the top level; `data` is what v1 sends, without `senderId`. 3. `PUT /v2/webhook` again with `"disableSenderWebhooks": true`. That switches the v1 sender webhooks off and keeps their configuration, so each event arrives once. The v1 project webhook (`/v1/invitations/webhook`) is separate and is not switched off. Details in [Webhook](/api-v2/webhook). ## What behaves differently in v2 Same engine, same limits and prices. These are the places where a v2 call answers differently from the same v1 call. ### Sending * **Only active connections are picked.** Without `channel`, a connection that is switched off is skipped. If nothing active can reach the recipient, the answer is `409 no_channel`. Naming a switched-off transport in `channel` is `403 channel_inactive`. * **One answer for "nothing can send".** No channel, a `channel` that is not connected, an email `to` without email, a WhatsApp-only `messageType` without WhatsApp: all `409 no_channel`, with `details.connect` listing what to connect and the request for each. v1 answers several of these with `400`. * **Contradictions are refused.** An email `to` with a channel other than `email`, `channel: "email"` with a phone number, or a WhatsApp-only `messageType` with another `channel` is `400`, naming both fields. * **Bare digits are not guessed.** A `to` of 8 to 15 digits without `+` is a chat id. When nothing in the request can reach a chat id, v2 refuses it with `details.suggestion` holding the number with its `+`. A number written with spaces, dashes or parentheses after its `+` is read as E.164. * **Fallback checks first.** Before a failed WhatsApp message falls back to SMS, v2 checks that the connection it would fall back to is switched on and, for a +1 number, that the project has an approved 10DLC campaign. Otherwise the message stays `failed`, with `errorCode` `CHANNEL_INACTIVE` or `TEN_DLC_REQUIRED`. * **Replies stay in their thread.** With `conversationId`, `to` can be omitted, and a `to` that is not the thread's contact is `400`. The `202` carries `message.conversationId` when the thread already exists. * **10DLC refusals say how to fix them.** `403 ten_dlc_required` carries the registration as API calls, in order, and the channels that reach the same person without 10DLC. ### Messages * Amounts are USD rounded to 6 decimals. * `charged` says what an SMS or one-way SMS took from your balance. `costTotal` is not that number for SMS. * `errorCode` is a Zavu code. A delivery provider's own code is translated where its meaning is known and kept in `errorDetails.providerCode`. * `attemptedChannels` lists the transports tried before a fallback, and `riskHold` says whether the message is waiting for risk review. ### Contacts * An identifier another contact already holds on the same channel is `400 duplicate_identifier`, with `details.conflicts` naming that contact. v1 refuses it too, as a plain `400 invalid_request` with no `details`. * A phone identifier (`sms`, `whatsapp`, `voice`) must be E.164; WhatsApp also takes a BSUID. * Deleting a contact that is already gone is `404`, as on v1. ### Campaigns * Creating a campaign checks its channel: a `from` without that channel is `409 no_channel`, not a campaign that fails at send. * Adding recipients: a malformed recipient, or bare digits on a phone campaign, refuses the whole request with `400` and the path of the recipient. A well-formed recipient the campaign cannot reach is skipped and reported in `invalid`, with `code` `unreachable_by_channel` or `ten_dlc_required`. * Acting on a campaign in the wrong state (cancel a finished one, add recipients to or delete one that is not a draft) is `409 invalid_status`. v1 answers `400`. ### Channels, agents, domains * A Telegram token Telegram rejects is `400 invalid_bot_token`. * Connecting an agent to one-way SMS is `409 channel_cannot_receive`: one-way SMS receives nothing, so the agent would never answer. * `POST /v2/agents` lists every missing required field, not only the first. * `POST /v2/email-domains` validates `domain` before anything is created, and adding a domain the project already has returns it with `200`. A domain whose DKIM records could not be generated comes back `verifiable: false` with `setupError`. * Verifying an email domain answers `404` only for a domain that is not yours, and `503 verification_unavailable` when the DNS check could not run. ### Errors and webhooks * Every error is flat, and every `400 invalid_request` carries `details.errors` with field paths. A body that is not JSON is `400 invalid_json`, as on v1. * A known path with the wrong method is `405`, not `404`, on every v2 path except `/v2/numbers` and `/v2/functions`. * The v2 webhook cannot move straight from `v2` signatures back to `v1`; go through `v1+v2`, as in the other direction. ## What stays on v1 These have no v2 endpoint. Call them on v1 with the same API key, using `v1SenderId` from `GET /v2/channels` where they take a sender. | Area | v1 endpoints | | - | - | | Function code | Create, delete, deploy, rollback, deployments, logs, triggers, secrets, Git link (`/v1/functions`) | | Voice calls | `/v1/calls` | | 10DLC registration | `/v1/10dlc/brands`, `/v1/10dlc/campaigns` | | Regulatory information | `/v1/phone-numbers/requirements`, `/v1/addresses`, `/v1/documents` | | Owned numbers | List, get, rename, release (`/v1/phone-numbers`) | | Agent internals | Tools, flows and knowledge bases (`/v1/agents/{agentId}/...`); executions and stats; voices; factory agents | | Memory | `/v1/memory` | | Contacts, beyond CRUD | Add, update and remove a contact's channels, merge, look up by phone | | Campaigns, beyond the basics | Update, reschedule, retry review, escalate, list and remove recipients | | Messages, beyond send and read | Reactions, typing indicator, attachments | | Templates | Sync from WhatsApp (`/v1/templates/sync`) | | WhatsApp | Business profile, history and contacts sync | | Account | `/v1/me`, `/v1/balance`, sub-accounts, partner invitations, URL verification, phone and email introspection | # API v2 Source: https://docs.zavu.dev/api-v2/overview A smaller Zavu API on the same engine as v1. No sender object: you send from an address. One project webhook, flat errors, and a 409 that tells you what to connect. ## What v2 is v2 is a second version of the Zavu REST API. It runs on the same engine as v1: the same API keys, the same sending limits, prices and reviews. v1 keeps working unchanged, and you can call both from the same code. What v2 changes is the shape: * **No sender object.** You send from an **address**, the way the recipient sees it: a phone number, an email address, a bot name. * **No `Zavu-Sender` header.** Pass `from`, or reply inside a thread with `conversationId`. * **Every send says how it was routed.** The `202` carries `routing`: the channel, the address it left from, and why. * **One webhook for the project.** Every event, at one URL. * **One error shape.** `{code, message, details}` at the root of every error. Coming from v1? Read [Migrate from v1](/api-v2/migrate-from-v1). ## Base URL and authentication ``` https://api.zavu.dev/v2 ``` Authenticate with the same API key you use on v1, as a Bearer token: ```bash theme={null} curl https://api.zavu.dev/v2/channels \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` The [rate limit](/concepts/rate-limiting) is per project and shared: v1 and v2 requests count against the same window. [Sending limits](/concepts/sending-limits) apply to a message the same way whichever version sent it. A test-mode key sends from the WhatsApp sandbox on v2, as it does on v1. `from` and `conversationId` are not consulted. > [!NOTE] > > The TypeScript SDK calls v2 as `client.v2`, from `@zavudev/sdk` 0.59.0: see > [TypeScript SDK: API v2](/sdks/typescript/v2). The other SDKs (`zavudev` on > PyPI and the rest) call v1 only; from those languages, call v2 over HTTP. ```typescript theme={null} import Zavudev from '@zavudev/sdk'; const zavu = new Zavudev(); // reads ZAVUDEV_API_KEY for await (const entry of zavu.v2.channels.list()) { console.log(entry.address, entry.type, entry.active); } ``` ## Addresses An address is where a message leaves from, written the way the recipient sees it. | Type | Address | | - | - | | `sms`, `voice`, `whatsapp` | E.164 number, e.g. `+13125551212` | | `email` | The email address, lowercase | | `telegram` | `@botname` | | `instagram` | `@username` | | `messenger` | The Facebook Page id | | `sms_oneway` | The literal `sms_oneway`. One-way SMS leaves from shared numbers, never a number of your own. | `GET /v2/channels` lists one entry per (address, type). A number that carries WhatsApp and SMS is two entries with the same address. `active: false` means the connection exists but is switched off. ```json theme={null} { "items": [ { "address": "+13125551212", "type": "whatsapp", "active": true, "v1SenderId": "kd7..." }, { "address": "+13125551212", "type": "sms", "active": true, "v1SenderId": "kd7..." }, { "address": "noreply@yourdomain.com", "type": "email", "active": true, "v1SenderId": "kd7..." } ], "nextCursor": null } ``` More in [Addresses and channels](/api-v2/addresses). ## Send your first message **1. Connect a channel** `POST /v2/channels` connects what needs no purchase and no Meta signup. Pick one: ```bash Email theme={null} # Needs a verified email domain. See "Email domains" below. curl -X POST https://api.zavu.dev/v2/channels \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{"type": "email", "address": "noreply@yourdomain.com", "fromName": "Acme"}' ``` ```bash Telegram theme={null} # A bot token from @BotFather. Its address is @botname. curl -X POST https://api.zavu.dev/v2/channels \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{"type": "telegram", "botToken": "123456789:AA..."}' ``` ```bash One-way SMS theme={null} # No number or credential. Recipients cannot reply. US and Canadian (+1) numbers # need an approved 10DLC campaign first (403 ten_dlc_required): use email or Telegram there. curl -X POST https://api.zavu.dev/v2/channels \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{"type": "sms_oneway"}' ``` The answer lists the entries of the address you connected. Two-way SMS and voice come with a number of your own (`POST /v2/numbers`). WhatsApp, Instagram and Messenger connect through Meta, from **Accounts** in the dashboard. **2. Send** In a new project the first channel lands on the default, so `from` is not needed. ```bash theme={null} curl -X POST https://api.zavu.dev/v2/messages \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "maria@example.com", "subject": "Your order shipped", "text": "Order ORD-12345 is on its way." }' ``` ```json 202 Accepted theme={null} { "message": { "id": "jd7x2k3m4n5p6q7r8s9t0", "to": "maria@example.com", "from": "noreply@yourdomain.com", "channel": "email", "direction": "outbound", "messageType": "text", "status": "queued", "createdAt": "2026-10-14T15:04:05.000Z" }, "routing": { "channel": "email", "from": "noreply@yourdomain.com", "reason": "recipient_type", "estimatedCost": null } } ``` **3. Pick the sending address** With more than one address, name it in `from`: ```bash theme={null} curl -X POST https://api.zavu.dev/v2/messages \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{"from": "+13125551212", "to": "+56912345678", "text": "Your code is 123456."}' ``` ### Email domains An email address can only be connected at a verified domain of the project: 1. `POST /v2/email-domains` with `{"domain": "yourdomain.com"}`. The answer has the DNS records to publish. 2. Publish them exactly as returned. 3. `POST /v2/email-domains/{emailDomainId}/verify`. 4. `POST /v2/channels` with `{"type": "email", "address": "noreply@yourdomain.com"}`. Connecting an address at a domain that is missing or not verified answers `409 email_domain_missing` or `409 email_domain_not_verified`, with the call that fixes it in `details.recommendation`. ## How a send is routed The message leaves from `conversationId` if given, else `from`, else the project default. The transport is the first rule that applies, reported in `routing.reason`: | `reason` | When | | - | - | | `sandbox` | A test-mode key. Always the WhatsApp sandbox. | | `recipient_type` | `to` is an email address, so email. | | `message_type` | `messageType` is WhatsApp-only (media, interactive, template), so WhatsApp. | | `explicit` | `channel` was given. | | `conversation` | `channel` omitted, and the sender came from `conversationId`. | | `auto_order` | `channel` omitted: the first active transport, for a phone number WhatsApp, then SMS, then one-way SMS. | The order does not compare price or check the WhatsApp 24-hour window. A connection that is switched off is skipped. A WhatsApp-to-SMS fallback happens after the `202`, so it never appears in `routing`; read `attemptedChannels` on the message. ## When nothing can send: `409 no_channel` Every "nothing can send this" is the same answer: no channel at all, a `channel` that is not connected, an email `to` without email, a WhatsApp-only `messageType` without WhatsApp. `details.connect` lists what this project can connect to reach `to`, with the exact request for each. ```json 409 Conflict theme={null} { "code": "no_channel", "message": "This project has no channel that can send to +56912345678 yet.", "details": { "recommendation": "Connect one with POST /v2/channels {\"type\":\"sms_oneway\"}, or POST /v2/numbers, or from Accounts in the dashboard. `details.connect` says what each needs.", "connect": [ { "type": "sms_oneway", "request": { "method": "POST", "path": "/v2/channels", "body": { "type": "sms_oneway" } }, "note": "One-way SMS from shared numbers; recipients cannot reply. US and Canadian (+1) numbers need an approved 10DLC campaign first: reach them by email or Telegram meanwhile.", "available": true }, { "type": "sms", "request": { "method": "POST", "path": "/v2/numbers", "body": { "phoneNumber": "" } }, "note": "Two-way SMS from a number of your own. Buying one needs a paid plan.", "available": true }, { "type": "whatsapp", "request": null, "note": "Connected through Meta's signup, from Accounts in the dashboard.", "available": true, "dashboardUrl": "/accounts" } ] } } ``` `request: null` means the channel is connected from the dashboard. `available: false` says why in `note`, for example one-way SMS before business verification is approved. ## Replying in a conversation Pass `conversationId` instead of `from`. The reply leaves from the address the thread was last handled from and goes to whoever the thread is with, so `to` can be omitted. ```bash theme={null} curl -X POST https://api.zavu.dev/v2/messages \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{"conversationId": "js723987cyghwqxxaxcf590qd18axd95", "text": "Thanks, we are on it."}' ``` A `to` that is not the thread's contact is refused with `400`, rather than starting another conversation. ## Webhook One webhook per project, configured with `PUT /v2/webhook`, receives every event you subscribe to. Payloads name addresses, never a sender id. See [Webhook](/api-v2/webhook). ## Errors Every error is flat: ```json theme={null} { "code": "invalid_request", "message": "Invalid request body", "details": { "errors": [{ "path": ["to"], "message": "Required" }] } } ``` A known path called with the wrong method answers `405 method_not_allowed`, with `details.allowed` and an `Allow` header. See [Errors](/api-v2/errors). ## Money Every amount in v2 is USD, rounded to 6 decimals. `routing.estimatedCost` is what an SMS took from your balance when it was accepted; it is `null` where the price is not known at send time (WhatsApp, email, voice). `message.charged` is what an SMS has taken so far. ## Reference Every operation, with its request and response schemas, is listed under **API v2 reference** in this tab. # Connect an address Source: https://docs.zavu.dev/api-v2/reference/agents/connect-an-address /api-reference/v2/openapi.json post /v2/agents/{agentId}/channels Make the agent answer on an address. Connecting `{address, type}` connects the whole channel group that entry belongs to (the entries of `GET /v2/channels` that share its `v1SenderId`): the agent then answers on each transport of that group that its `triggerOnChannels` allows, which `channels` on the agent lists. `type` is what picks the group when two groups share the address. An address answers with one agent at a time; connecting one already answered by another agent returns `400` naming that agent. # Create an agent Source: https://docs.zavu.dev/api-v2/reference/agents/create-an-agent /api-reference/v2/openapi.json post /v2/agents Connect it to an address with `POST /v2/agents/{agentId}/channels`. # Delete an agent Source: https://docs.zavu.dev/api-v2/reference/agents/delete-an-agent /api-reference/v2/openapi.json delete /v2/agents/{agentId} As `DELETE /v1/agents/{agentId}`. # Disconnect an address Source: https://docs.zavu.dev/api-v2/reference/agents/disconnect-an-address /api-reference/v2/openapi.json delete /v2/agents/{agentId}/channels/{address}/{type} Stop the agent answering on the address. This disconnects the channel group behind `{address, type}`, so every transport of that group stops reaching the agent. An agent created on /v1 for a sender keeps that sender: its addresses cannot be disconnected here, and that answers `400`. # Get an agent Source: https://docs.zavu.dev/api-v2/reference/agents/get-an-agent /api-reference/v2/openapi.json get /v2/agents/{agentId} # List agents Source: https://docs.zavu.dev/api-v2/reference/agents/list-agents /api-reference/v2/openapi.json get /v2/agents # Test an agent Source: https://docs.zavu.dev/api-v2/reference/agents/test-an-agent /api-reference/v2/openapi.json post /v2/agents/{agentId}/test Run the agent's prompt, model and knowledge base against a message and return the reply instead of delivering it. Nothing is delivered and no execution is recorded. **Billed like a live reply.** On an agent with the `zavu` provider the run is charged to your balance at the same price per token, and refused with `402 insufficient_balance` at a balance of zero; on a bring-your-own-key agent it costs your provider. `cost` on the response is what it charged. Runs are rate limited per project (`429`). **Tools.** By default the model is offered the agent's enabled tools but none is run: if it picks one, the reply stops there and `plannedToolCalls` says which, with what arguments. Set `executeTools` to run them. That reaches your real handlers, with whatever they cost and change; read `executedToolCalls`. # Update an agent Source: https://docs.zavu.dev/api-v2/reference/agents/update-an-agent /api-reference/v2/openapi.json patch /v2/agents/{agentId} # Add recipients Source: https://docs.zavu.dev/api-v2/reference/campaigns/add-recipients /api-reference/v2/openapi.json post /v2/campaigns/{campaignId}/recipients Up to 1,000 per call. Recipients are held to the rules `POST /v2/messages` holds `to` to. Validation happens in two steps with different outcomes: 1. **The request as a whole.** A phone number written with spaces, dashes, dots or parentheses after its `+` is read as the E.164 number first. Then a recipient that is not a phone number, email address, BSUID or chat id makes the whole request `400`, with `details.errors[].path` naming it (`["contacts", 2, "recipient"]`); so do 8-15 bare digits on an `sms`, `sms_oneway`, `whatsapp`, `voice` or `smart` campaign, with `details.errors[].suggestion` (`+`), as a send refuses them. Nothing is added. 2. **Each recipient against the campaign.** A well-formed recipient the campaign cannot reach is skipped and reported in `invalid` and `errors[{recipient, reason, code?}]`: `code: "unreachable_by_channel"` for an email address on a phone campaign or a phone number on an email campaign; `code: "ten_dlc_required"` for a US/Canadian (+1) number on an `sms` or `sms_oneway` campaign while this project has no approved 10DLC campaign (the send would refuse it, as `POST /v2/messages` answers `403 ten_dlc_required`). Repeats of a recipient already in the campaign or in the same call are skipped as `duplicates`. The others are added, and the call answers `201`. So `201` can mean some were not added: check `invalid`. A campaign that is not a draft answers `409 invalid_status`. # Cancel a campaign Source: https://docs.zavu.dev/api-v2/reference/campaigns/cancel-a-campaign /api-reference/v2/openapi.json post /v2/campaigns/{campaignId}/cancel As `POST /v1/broadcasts/{broadcastId}/cancel`. Recipients not yet sent are skipped; messages already queued may still be delivered. # Create a campaign Source: https://docs.zavu.dev/api-v2/reference/campaigns/create-a-campaign /api-reference/v2/openapi.json post /v2/campaigns A campaign is a /v1 broadcast; the id works on `/v1/broadcasts/{broadcastId}`. Runs `POST /v1/broadcasts` with the sender resolved from `from`. A repeated `idempotencyKey` answers `409 conflict` with the original in `details.campaign`. A `channel` the resolved sender does not carry (`smart`: no channel at all) is refused at creation with `409 no_channel`, as on `POST /v2/messages`, rather than created and failed at send. `from` with a `channel` it does not carry (a `sms` campaign `from: "sms_oneway"`) is that same `409 no_channel`, in the words `POST /v2/messages` uses (`details.connected`, and `sms` vs `sms_oneway` told apart); `channel` only picks between senders that share an address, it never makes an existing address `404`. # Delete a draft campaign Source: https://docs.zavu.dev/api-v2/reference/campaigns/delete-a-draft-campaign /api-reference/v2/openapi.json delete /v2/campaigns/{campaignId} Deletes a campaign that is still a `draft`, as `DELETE /v1/broadcasts/{broadcastId}`. Any other status is refused with `409 invalid_status` (`details.status`): a campaign that was sent or cancelled is kept as the record of what went out, and one still in review, scheduled or sending is cancelled with `POST /v2/campaigns/{campaignId}/cancel` (`details.recommendation`). /v1 refuses the same with a `400`. # Get a campaign Source: https://docs.zavu.dev/api-v2/reference/campaigns/get-a-campaign /api-reference/v2/openapi.json get /v2/campaigns/{campaignId} # Get campaign progress Source: https://docs.zavu.dev/api-v2/reference/campaigns/get-campaign-progress /api-reference/v2/openapi.json get /v2/campaigns/{campaignId}/progress # List campaigns Source: https://docs.zavu.dev/api-v2/reference/campaigns/list-campaigns /api-reference/v2/openapi.json get /v2/campaigns Campaigns of this project, newest first, as `GET /v1/broadcasts`, each with `from` instead of `senderId`. Cursor pagination: pass `nextCursor` back as `cursor`. # Send or schedule a campaign Source: https://docs.zavu.dev/api-v2/reference/campaigns/send-or-schedule-a-campaign /api-reference/v2/openapi.json post /v2/campaigns/{campaignId}/send Same review rules as `POST /v1/broadcasts/{broadcastId}/send`: a WhatsApp campaign on an approved template starts directly, email starts once automated review passes, other channels wait for a person. Omit `scheduledAt` to send now. # Activate a transport Source: https://docs.zavu.dev/api-v2/reference/channels/activate-a-transport /api-reference/v2/openapi.json post /v2/channels/{address}/{type}/activate The same switch as **Activate** on Accounts in the dashboard, with the same price and limits: on a paid plan a month is charged at once, stated in `chargedCents` and `monthlyCents`. `activated: false` means it was already on and nothing was charged. # Connect a channel Source: https://docs.zavu.dev/api-v2/reference/channels/connect-a-channel /api-reference/v2/openapi.json post /v2/channels Connect a channel without buying anything, so a new project can send its first message from /v2. Returns every entry of the connected address (`GET /v2/channels/{address}`). **Where it lands.** Channels are grouped: the entries of `GET /v2/channels` that share a `v1SenderId` are one group, and a group holds one connection per transport. The new channel joins the project's default group when that group does not carry the transport yet; otherwise, or when the project has none, it starts a new group (the first group of a project becomes its default). So in a fresh project every channel connected here lands on the default and `POST /v2/messages` needs no `from`; a second email address or a second bot starts its own group and is used by passing its address as `from`. **Idempotent** for `sms_oneway` and `email`: an address already connected answers `200` with its entries and changes nothing. **Active or not.** One-way SMS is active at once. An email or Telegram connection the plan includes is switched on by itself; one that would cost money is listed with `active: false` and is switched on with `POST /v2/channels/{address}/{type}/activate` (same price and limits as on /v1). A Telegram token that Telegram refuses leaves nothing behind. # Deactivate a transport Source: https://docs.zavu.dev/api-v2/reference/channels/deactivate-a-transport /api-reference/v2/openapi.json post /v2/channels/{address}/{type}/deactivate Switch the connection off. It stays connected, so activating it again needs no reconnection. The month already paid is not refunded. # Disconnect a channel Source: https://docs.zavu.dev/api-v2/reference/channels/disconnect-a-channel /api-reference/v2/openapi.json delete /v2/channels/{address}/{type} Disconnect one (address, type). `sms_oneway`, `telegram` and `voice` can be disconnected here. The others answer `409 disconnect_unavailable` saying what to do instead: `whatsapp`, `instagram`, `messenger` and `email` can be switched off with `.../deactivate` (they stay connected); two-way `sms` comes with the address's number. # Get the entries of one address Source: https://docs.zavu.dev/api-v2/reference/channels/get-the-entries-of-one-address /api-reference/v2/openapi.json get /v2/channels/{address} Every transport the address carries, one entry each. # List channels Source: https://docs.zavu.dev/api-v2/reference/channels/list-channels /api-reference/v2/openapi.json get /v2/channels Every (address, type) the project can send from, and whether it can send right now. Paginated by sender: a page holds every entry of up to `limit` senders, so it can hold more than `limit` entries. # Create a contact Source: https://docs.zavu.dev/api-v2/reference/contacts/create-a-contact /api-reference/v2/openapi.json post /v2/contacts Create a contact with one or more channels. An identifier another contact already holds on the same channel is refused (`400 duplicate_identifier`, with `details.conflicts[{channel, identifier, contactId}]`); update the existing contact with `PATCH /v2/contacts/{contactId}` instead. # Delete a contact Source: https://docs.zavu.dev/api-v2/reference/contacts/delete-a-contact /api-reference/v2/openapi.json delete /v2/contacts/{contactId} Permanently delete a contact and its channels (right to erasure). Conversations, message history and campaign delivery logs are kept and no longer reference it. # Get a contact Source: https://docs.zavu.dev/api-v2/reference/contacts/get-a-contact /api-reference/v2/openapi.json get /v2/contacts/{contactId} # List contacts Source: https://docs.zavu.dev/api-v2/reference/contacts/list-contacts /api-reference/v2/openapi.json get /v2/contacts List contacts with their channels. A repeated `tag` means all of them. # Update a contact Source: https://docs.zavu.dev/api-v2/reference/contacts/update-a-contact /api-reference/v2/openapi.json patch /v2/contacts/{contactId} # Get a conversation Source: https://docs.zavu.dev/api-v2/reference/conversations/get-a-conversation /api-reference/v2/openapi.json get /v2/conversations/{conversationId} # List a conversation's messages Source: https://docs.zavu.dev/api-v2/reference/conversations/list-a-conversations-messages /api-reference/v2/openapi.json get /v2/conversations/{conversationId}/messages Reply with `POST /v2/messages` and this `conversationId`. # List conversations Source: https://docs.zavu.dev/api-v2/reference/conversations/list-conversations /api-reference/v2/openapi.json get /v2/conversations List inbox threads, most recently active first. Filter by `from` (one of your addresses) and `channel`. Each conversation carries `from`, the address it was last handled from. # Mark a conversation read Source: https://docs.zavu.dev/api-v2/reference/conversations/mark-a-conversation-read /api-reference/v2/openapi.json post /v2/conversations/{conversationId}/read Resets `unreadCount` in your inbox. Sends no read receipt to the contact. # Add an email domain Source: https://docs.zavu.dev/api-v2/reference/email-domains/add-an-email-domain /api-reference/v2/openapi.json post /v2/email-domains Add a domain to send email from. `domain` is validated first: a bare domain (a leading `https://` and a trailing path are dropped); anything else, spaces included, is `400` with `details.errors[0].path` `["domain"]`. Adding a domain the project already has returns it with `200` (nothing created), like `POST /v2/channels`. Returns the DNS records to publish; the DKIM CNAMEs are required. If they could not be generated, the domain is still created (`201`) but comes back `verifiable: false` with `setupError`: remove it and add it again, or contact support. Verify cannot fix it. Publish the records exactly as returned, verify, then connect an address with `POST /v2/channels {"type":"email"}`. # Get an email domain Source: https://docs.zavu.dev/api-v2/reference/email-domains/get-an-email-domain /api-reference/v2/openapi.json get /v2/email-domains/{emailDomainId} The domain with its DNS records and status. # List email domains Source: https://docs.zavu.dev/api-v2/reference/email-domains/list-email-domains /api-reference/v2/openapi.json get /v2/email-domains Email domains of this project. # Remove an email domain Source: https://docs.zavu.dev/api-v2/reference/email-domains/remove-an-email-domain /api-reference/v2/openapi.json delete /v2/email-domains/{emailDomainId} # Verify an email domain Source: https://docs.zavu.dev/api-v2/reference/email-domains/verify-an-email-domain /api-reference/v2/openapi.json post /v2/email-domains/{emailDomainId}/verify Re-check the domain's DNS records and refresh its status. `404` only for a domain that is not this project's; `503 verification_unavailable` when the check could not run (nothing changed, retry). A domain with no DKIM records (`verifiable: false`) cannot verify whatever DNS says: verify runs the check (it may recover the records), and when the domain still has none it answers `409 domain_setup_failed` with the same `setupError` the domain carries. # Configure a function Source: https://docs.zavu.dev/api-v2/reference/functions/configure-a-function /api-reference/v2/openapi.json patch /v2/functions/{functionId} Name, description, timeout, memory, architecture, pause and the HTTP endpoint, with the same rules as the dashboard's Settings tab. # Get a function Source: https://docs.zavu.dev/api-v2/reference/functions/get-a-function /api-reference/v2/openapi.json get /v2/functions/{functionId} # List functions Source: https://docs.zavu.dev/api-v2/reference/functions/list-functions /api-reference/v2/openapi.json get /v2/functions The project's functions, newest first. # Get a message Source: https://docs.zavu.dev/api-v2/reference/messages/get-a-message /api-reference/v2/openapi.json get /v2/messages/{messageId} A message, with `from` and `to` naming addresses, plus `attemptedChannels` and `riskHold`. # List messages Source: https://docs.zavu.dev/api-v2/reference/messages/list-messages /api-reference/v2/openapi.json get /v2/messages List messages of this project, newest first. # Send a message Source: https://docs.zavu.dev/api-v2/reference/messages/send-a-message /api-reference/v2/openapi.json post /v2/messages Send one message. The body is the /v1 message body plus `from` and `conversationId`. There is no `Zavu-Sender` header. **Where it leaves from:** `conversationId` if given, else `from` (an address), else the project default. See `from` for ambiguous addresses. **Which transport:** decided by the first branch that applies, and reported in `routing.reason`: a test-mode key uses the WhatsApp sandbox; an email-address `to` uses email; a WhatsApp-only `messageType` uses WhatsApp; an explicit `channel` is used as given; otherwise the fixed order over the sender's ACTIVE transports (for a phone number: WhatsApp, then SMS, then one-way SMS). The order ignores price and the 24-hour window. A `channel` that contradicts the recipient (an email `to` on anything but `email`, or `channel: "email"` with a phone number or chat id) or a WhatsApp-only `messageType` is refused with `400`; /v1 overrides the first and last silently. **When nothing can send** the answer is always `409 no_channel`, whatever the reason: no channel at all, an explicit `channel` that is not connected, an email `to` without an email channel, a WhatsApp-only `messageType` without WhatsApp. `details.connect` lists what this project can connect to reach `to`, with the exact call for each (`POST /v2/channels`, `POST /v2/numbers`), and the message never names an address that carries nothing. **Fallback** from WhatsApp to SMS, when enabled, happens after this call returned, once the WhatsApp send fails. It is not visible in `routing`; read `attemptedChannels` on the message or wait for the webhooks. Before falling back to SMS, a message sent through /v2 is checked for an approved 10DLC campaign (+1 numbers) and an active connection; when either is missing it stays `failed` with `errorCode` `TEN_DLC_REQUIRED` or `CHANNEL_INACTIVE`. Limits, risk review, pricing and verification are exactly /v1's: see `POST /v1/messages`. # Buy a number Source: https://docs.zavu.dev/api-v2/reference/numbers/buy-a-number /api-reference/v2/openapi.json post /v2/numbers Buy a number and make it an SMS channel in the same call. The purchase follows the same rules as `POST /v1/phone-numbers`, including regulatory requirements, and the number comes back with SMS on. There is no separate assign step. Where it lands: when the project's default channel group has no number yet, the number joins that group (and becomes the project's first channel and default when there is none); otherwise it gets a channel group of its own. While the number is in regulatory review (`number.regulatoryStatus: pending_review`) it is attached but cannot send, and `channels` can be empty until it is approved. If the purchase succeeded but turning SMS on failed, the number is still bought and billed; the response says so in `setupError` rather than failing the call. # Search numbers to buy Source: https://docs.zavu.dev/api-v2/reference/numbers/search-numbers-to-buy /api-reference/v2/openapi.json get /v2/numbers/available Search numbers to buy, by country and type. # Create a template Source: https://docs.zavu.dev/api-v2/reference/templates/create-a-template /api-reference/v2/openapi.json post /v2/templates # Delete a template Source: https://docs.zavu.dev/api-v2/reference/templates/delete-a-template /api-reference/v2/openapi.json delete /v2/templates/{templateId} As `DELETE /v1/templates/{templateId}`. # Get a template Source: https://docs.zavu.dev/api-v2/reference/templates/get-a-template /api-reference/v2/openapi.json get /v2/templates/{templateId} # List templates Source: https://docs.zavu.dev/api-v2/reference/templates/list-templates /api-reference/v2/openapi.json get /v2/templates # Submit a template to WhatsApp Source: https://docs.zavu.dev/api-v2/reference/templates/submit-a-template-to-whatsapp /api-reference/v2/openapi.json post /v2/templates/{templateId}/submit Submit a draft template to Meta for approval, naming the WhatsApp number by its address (`from`). # Create or replace the project webhook Source: https://docs.zavu.dev/api-v2/reference/webhook/create-or-replace-the-project-webhook /api-reference/v2/openapi.json put /v2/webhook Create or replace the webhook. `url` must be HTTPS. The signature scheme defaults to `v2`. A webhook cannot move straight from `v1` to `v2`, or from `v2` to `v1` (`400`): a receiver verifying the current scheme would break on the next delivery, so both directions go through `v1+v2`. Replacing keeps the secret. Pass `disableSenderWebhooks: true` to switch off the /v1 sender webhooks of this project in the same call, so each event arrives once. # Get the project webhook Source: https://docs.zavu.dev/api-v2/reference/webhook/get-the-project-webhook /api-reference/v2/openapi.json get /v2/webhook One webhook for the whole project and every event, `message.*`, `conversation.new` and `call.*` included. Payloads name addresses (`from`, `to`) and the transport (`type`), never a sender id; see `WebhookPayloadV2`. **Alongside /v1 webhooks.** Configuring this webhook is the opt-in. From then on each event is delivered here *in addition to* any /v1 sender webhook or /v1 project webhook subscribed to it, as independent deliveries with different event ids. Nothing is deduplicated. `senderWebhooks` lists the /v1 sender webhooks still on; switch them off with `disableSenderWebhooks` on `PUT` once this one is verified. It does not share storage with `/v1/invitations/webhook`. # Remove the webhook Source: https://docs.zavu.dev/api-v2/reference/webhook/remove-the-webhook /api-reference/v2/openapi.json delete /v2/webhook Remove the /v2 webhook and its secret. /v1 sender webhooks are not touched, including any `disableSenderWebhooks` switched off: they stay off. # Rotate the webhook secret Source: https://docs.zavu.dev/api-v2/reference/webhook/rotate-the-webhook-secret /api-reference/v2/openapi.json post /v2/webhook/secret # Webhook Source: https://docs.zavu.dev/api-v2/webhook One webhook per project in API v2: configure it, read its payload, verify its signature, and switch off the v1 sender webhooks when you are ready. ## One webhook for the project v2 has one webhook per project. It receives every event it is subscribed to, from every address: messages, conversations, calls, templates, campaigns, domains and invitations. ```bash theme={null} curl -X PUT https://api.zavu.dev/v2/webhook \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://api.example.com/webhooks/zavu", "events": ["message.inbound", "message.delivered", "message.failed", "conversation.new"] }' ``` The `201` that creates it returns `webhook.secret` once. Keep it: later reads do not return it. `POST /v2/webhook/secret` rotates it, and the old one stops working at once. | Call | What it does | | - | - | | `GET /v2/webhook` | The webhook (`null` when none) and the v1 sender webhooks still on. | | `PUT /v2/webhook` | Create or replace it. Replacing keeps the secret. | | `POST /v2/webhook/secret` | Rotate the secret. | | `DELETE /v2/webhook` | Remove it and its secret. | `url` must be HTTPS. An event name that does not exist is refused with `400`, with the valid names in `details.allowed`. ## Payload ```json theme={null} { "id": "evt_1736850000000_abc123", "event": "message.inbound", "timestamp": 1736850000000, "projectId": "jx7xyz789ghi012", "from": "+56912345678", "to": "+13125551212", "type": "whatsapp", "data": { "messageId": "jx7744x3wckc9cwy0j9c8g6v0h7z7shs", "from": "+56912345678", "to": "+13125551212", "channel": "whatsapp", "messageType": "text", "text": "Hi, is my order on its way?" } } ``` | Field | Meaning | | - | - | | `event` | The event name, also sent in the `X-Zavu-Event` header. | | `from` | Outbound message events: your address. Inbound events and `conversation.new`: the contact. Calls: the caller. | | `to` | The other end. | | `type` | The transport, as in `GET /v2/channels`. | | `data` | The same event data v1 sends, without `senderId`. | No sender id appears anywhere in the payload. Project-level events (`invitation.*`, `domain.*`, `broadcast.*`) are not about one address: read `data` for them. The event names and their `data` are listed under `WebhookEvent` in the reference, and explained in [Webhook events](/guides/receiving-messages/events). ## Signature Every delivery carries `X-Zavu-Signature`, signed with the webhook's secret: ``` X-Zavu-Signature: t=1786113454,v2=b4b2b61cdcdb241e4270298a08c3c13c964642df... ``` `v2` is `HMAC_SHA256(secret, "{t}.{body}")` over the raw body, and it is the default for a new v2 webhook. `signatureVersion` can also be `v1` or `v1+v2`. Moving straight between `v1` and `v2` is refused with `400`: go through `v1+v2`. Verification code is in [Security](/guides/receiving-messages/security). ## Retries A delivery that does not get a `2xx` is retried, up to 5 attempts in total: after 1 minute, 5 minutes, 15 minutes and 1 hour. Each event has its own `id`; use it to skip a delivery you already processed. ## Next to v1 webhooks Setting up the v2 webhook is the opt-in. From then on each event goes to it **in addition to** any v1 sender webhook or v1 project webhook subscribed to the same event. They are separate deliveries with different event ids, and nothing is deduplicated. `senderWebhooks` on `GET /v2/webhook` lists the v1 sender webhooks still on. Once your v2 receiver works, switch them off in the same call: ```bash theme={null} curl -X PUT https://api.zavu.dev/v2/webhook \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://api.example.com/webhooks/zavu", "events": ["message.inbound", "message.delivered", "message.failed", "conversation.new"], "disableSenderWebhooks": true }' ``` They are only switched off. URL, events and secret stay, so v1 can turn one back on. `DELETE /v2/webhook` does not turn them back on. The v2 webhook is stored apart from the v1 project webhook (`/v1/invitations/webhook`). Neither API reads or writes the other's. # Authentication Source: https://docs.zavu.dev/authentication Secure your Zavu API requests with Bearer tokens. Test and live keys, project scoping, key rotation and best practices for keeping production ## API Keys All API requests require authentication using a Bearer token in the Authorization header. ```bash theme={null} Authorization: Bearer zv_live_xxxxxxxxxxxxxxxxxxxxxxxx ``` ## Key Types | Prefix | Environment | Usage | | - | - | - | | `zv_live_` | Production | Real messages, real costs | | `zv_test_` | Sandbox | WhatsApp only, to your team's own phones, from Zavu's sandbox number | > [!WARNING] > > **A test key delivers real messages.** It does not simulate anything: the > message goes to WhatsApp and arrives on the handset. What makes it safe is who > it can reach, not whether it sends. > > A `zv_test_` key ignores your senders entirely and sends WhatsApp from Zavu's > shared sandbox number, only to a phone belonging to someone on your team. Any > other channel is refused with `400`, an unlisted recipient with `403`, and > `POST /v1/calls` with `400`. Use it to exercise the API before you have a > WhatsApp Business Account of your own; use a `zv_live_` key for everything else. > > See [Sending limits](/concepts/sending-limits#test-mode-keys). ## Creating API Keys Your account is created with a live key already in it. It is on the dashboard home, masked, with a button to show it and a button to copy it — there is nothing to create before your first call. To create another (one per machine, or one per environment): 1. Log in to your [Zavu Dashboard](https://dashboard.zavu.dev) 2. Navigate to **API Keys** 3. Click **Create API Key** 4. Give it a descriptive name (e.g., "Production Server", "Development") 5. Copy the key > [!NOTE] > > A key can be shown again from the **API Keys** screen whenever you need it, so > losing the tab you created it in does not lose the key. Keys created before > this was possible are the exception: only their prefix is stored, and the way > back is to create a new one. Revoking a key stops it authenticating and stops > it being shown. ## Using API Keys ### In HTTP Requests Include the key in the `Authorization` header: ```bash theme={null} curl https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer zv_live_xxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" ``` ### In SDKs ```typescript TypeScript theme={null} import Zavudev from '@zavudev/sdk'; const zavu = new Zavudev({ apiKey: process.env['ZAVUDEV_API_KEY'], // This is the default and can be omitted }); ``` ```python Python theme={null} import os from zavudev import Zavudev zavu = Zavudev( api_key=os.environ.get("ZAVUDEV_API_KEY"), # This is the default and can be omitted ) ``` ```ruby Ruby theme={null} require "zavudev" client = Zavudev::Client.new(api_key: ENV["ZAVUDEV_API_KEY"]) ``` ```go Go theme={null} import ( "os" "github.com/zavudev/sdk-go" "github.com/zavudev/sdk-go/option" ) client := zavudev.NewClient(option.WithAPIKey(os.Getenv("ZAVUDEV_API_KEY"))) ``` ```php PHP theme={null} [!WARNING] > > Never expose your API keys in client-side code, public repositories, or browser applications. ### Do's * Store keys in environment variables * Use different keys for development and production * Rotate keys periodically (every 90 days recommended) * Use the minimum permissions needed * Monitor key usage in your dashboard ### Don'ts * Don't commit keys to version control * Don't share keys via email or chat * Don't use production keys in development * Don't embed keys in mobile apps or frontends ## Frontend Integration The Zavu API uses secret API keys that **must never be exposed in client-side code**. If you include your key in a browser app, anyone can open DevTools and steal it. Instead, use the **Backend-for-Frontend (BFF) pattern**: your frontend calls your own server endpoint, and your server calls the Zavu API. ### Server-side proxy examples ```typescript Next.js (App Router) theme={null} // app/api/send-message/route.ts import Zavudev from '@zavudev/sdk'; const zavu = new Zavudev(); // reads ZAVUDEV_API_KEY from env export async function POST(request: Request) { const { to, text } = await request.json(); const result = await zavu.messages.send({ to, text }); return Response.json(result); } ``` ```typescript Express.js theme={null} import express from 'express'; import Zavudev from '@zavudev/sdk'; const app = express(); app.use(express.json()); const zavu = new Zavudev(); // reads ZAVUDEV_API_KEY from env app.post('/api/send-message', async (req, res) => { const { to, text } = req.body; const result = await zavu.messages.send({ to, text }); res.json(result); }); ``` ```python FastAPI theme={null} from fastapi import FastAPI from pydantic import BaseModel from zavudev import Zavudev app = FastAPI() zavu = Zavudev() # reads ZAVUDEV_API_KEY from env class MessageRequest(BaseModel): to: str text: str @app.post("/api/send-message") async def send_message(req: MessageRequest): result = zavu.messages.send(to=req.to, text=req.text) return result ``` ```ruby Ruby (Sinatra) theme={null} require "sinatra" require "sinatra/json" require "zavudev" client = Zavudev::Client.new # reads ZAVUDEV_API_KEY from env post "/api/send-message" do body = JSON.parse(request.body.read) result = client.messages.send_(to: body["to"], text: body["text"]) json result end ``` ```go Go (net/http) theme={null} package main import ( "encoding/json" "net/http" "github.com/zavudev/sdk-go" "github.com/zavudev/sdk-go/option" ) var client = zavudev.NewClient() // reads ZAVUDEV_API_KEY from env func sendMessage(w http.ResponseWriter, r *http.Request) { var req struct { To string `json:"to"` Text string `json:"text"` } json.NewDecoder(r.Body).Decode(&req) result, _ := client.Messages.Send(r.Context(), zavudev.MessageSendParams{ To: zavudev.String(req.To), Text: zavudev.String(req.Text), }) json.NewEncoder(w).Encode(result) } func main() { http.HandleFunc("/api/send-message", sendMessage) http.ListenAndServe(":3000", nil) } ``` ```php PHP (Laravel) theme={null} messages->send([ 'to' => $request->input('to'), 'text' => $request->input('text'), ]); return response()->json($result); }); ``` ### Frontend example ```typescript React theme={null} async function sendMessage(to: string, text: string) { const res = await fetch('/api/send-message', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ to, text }), }); return res.json(); } ``` > [!NOTE] > > Add your own authentication and validation to the proxy endpoint. The examples above are simplified for clarity. ## Key Access Every API key has full access to the project it belongs to: it can send, read and change anything that project holds. Keys cannot be scoped to a subset of operations today. To limit what a piece of code can reach, give it a key from its own project. A [sub-account](/concepts/sub-accounts) is a separate project with its own keys, its own senders and data, and an optional spending limit. ## Revoking Keys If a key is compromised: 1. Go to **Settings** → **API Keys** 2. Find the compromised key 3. Click **Revoke** 4. Create a new key 5. Update your applications Revoked keys are immediately invalidated and cannot be restored. ## Error Responses | Status | Error | Description | | - | - | - | | `401` | `unauthorized` | Missing or invalid API key | | `403` | varies | The account is not allowed to do this, for example `sending_suspended` or `plan_limit_reached`. The `code` says which | | `429` | `rate_limit_exceeded` | Too many requests | ```json theme={null} { "error": { "code": "unauthorized", "message": "Invalid API key" } } ``` # How agents work Source: https://docs.zavu.dev/concepts/agent-architecture From TypeScript on your machine to an agent answering a phone call An agent on Zavu is one object that answers on every channel. The same prompt, model, knowledge base and skills serve a WhatsApp message and a phone call. There are two ways to get one, and they produce the same thing: * **In code** — declare it with `defineAgent` in a Zavu Function and deploy. The code is the source of truth. * **In the dashboard or over the API** — create and configure it directly. This page follows the code-first path end to end, because it is the one with moving parts you cannot see. ## The pieces ```mermaid theme={null} flowchart LR subgraph you["Your machine"] src["index.ts
defineAgent + defineTool"] end subgraph cloud["Zavu Cloud"] bw["Build Worker
bundles your code"] fn["Your Function
an isolated runtime"] rec["Reconciler
reads what you declared"] agent[("Agent
prompt · model · tools")] end src -->|npx zavudev deploy| bw bw -->|publishes| fn rec -->|asks it what it declares| fn rec -->|creates / updates| agent ``` Two things live in Zavu Cloud that are easy to conflate: * **Your Function** is your TypeScript, running in its own isolated runtime. It holds your tool handlers — the code that talks to your systems. * **The Agent** is the configuration a conversation runs against: the prompt, the model, which channels trigger it, its knowledge bases, and pointers to your tools. `npx zavudev deploy` publishes the first and derives the second from it. ## What a deploy actually does ```mermaid theme={null} sequenceDiagram participant Dev as You participant CLI as zavu CLI participant BW as Build Worker participant Fn as Your Function participant Rec as Reconciler Dev->>CLI: npx zavudev deploy CLI->>BW: source + dependencies BW->>BW: bundle (your runtime stays external) BW->>Fn: publish Rec->>Fn: "what do you declare?" Fn-->>Rec: manifest — agents + tools Rec->>Rec: create, update, delete to match Rec-->>CLI: summary + warnings CLI-->>Dev: ✓ Deployed ``` The step worth understanding is the **manifest**. After publishing, Zavu asks your function what it declared, and reconciles reality to match it. That is what makes the code the source of truth: * A `defineAgent` you added becomes a new agent. * A field you changed is updated. * A `defineAgent` you **deleted** deletes the agent. > [!WARNING] > > Read the lines **above** the ✓. `npx zavudev deploy` prints warnings before the success > line, and exits non-zero when your declarations were not synced at all. They > cover the cases where a green deploy did not do what it looks like — tools > attached to an agent whose channels will never call them, or a second agent > landing on a sender that already has one. ## A message arrives ```mermaid theme={null} sequenceDiagram participant User as Customer participant Ch as WhatsApp / SMS / … participant Zavu as Zavu participant Agent as Your agent participant KB as Knowledge base User->>Ch: "where is order ORD-1?" Ch->>Zavu: inbound message Zavu->>Zavu: does an agent answer on this sender? Zavu->>KB: retrieve relevant chunks KB-->>Zavu: context Zavu->>Agent: prompt + history + context Agent-->>Zavu: reply Zavu->>Ch: send Ch-->>User: reply ``` ## A call arrives Voice adds speech in and out around the same agent, and one important difference: **on a call the model is offered your tools.** ```mermaid theme={null} sequenceDiagram participant Caller participant Tel as Telephony participant VP as Voice pipeline participant Agent as Your agent participant Fn as Your Function Caller->>Tel: dials your number Tel->>VP: call connected VP-->>Caller: greeting Caller->>VP: speaks VP->>VP: speech → text VP->>Agent: transcript Agent->>Fn: check_availability(…) Fn-->>Agent: { ok: true, slots: [...] } Agent-->>VP: reply VP->>VP: text → speech VP-->>Caller: spoken answer ``` The caller can interrupt while the agent is speaking (barge-in) and the agent stops to listen. It can also transfer to a human, or end the call. ## Where tools run Everywhere. | Channel | Are tools offered to the model? | | - | - | | **Voice** | Yes | | A flow's `tool` step | Yes | | **Text** (WhatsApp, SMS, Telegram, email) | Yes | A text agent asked to look something up calls the tool and answers with what it returned. The model may chain up to five tool rounds in a single reply, and you are billed for the tokens the whole loop uses. [Flows](/guides/ai-agents/flows) are still worth reaching for, but for a different reason than they used to be: a flow runs the steps you wrote, in the order you wrote them. Use one when the sequence has to be deterministic rather than decided by the model. Two things this does not cover, both of which `npx zavudev agents test` will tell you about: * A dry run never *executes* a tool, because doing so would fire real webhooks from a test box. It reports which tools it withheld, so an answer that looks like a lookup is never mistaken for one. * An agent is only reachable on the channels its sender actually has. An agent whose sender has none, or none of the ones the agent triggers on, answers every dry run and no real message. ## What you can verify, and when Most of the loop is free and instant. The last step is not. ```mermaid theme={null} flowchart TD A["Write index.ts"] --> B["tsc — types resolve locally"] B --> C["npx zavudev fn invoke --tool
runs one handler, no deploy"] C --> D["npx zavudev deploy"] D --> E["npx zavudev agents list
confirm what landed"] E --> F["npx zavudev agents test
the agent's reply, nothing sent"] F --> G["A real message, or a real call"] G -.->|costs money| G ``` | Step | Cost | What it proves | | - | - | - | | `tsc` | free | Your code is valid against the runtime's types | | `fn invoke --tool` | free | A handler does what you think, in isolation | | `agents list` | free | The deploy created what you meant | | `agents test` | free | The prompt, model and retrieval produce the right answer | | A real message or call | billed | Everything else, including tool calling on voice | > [!NOTE] > > `agents test` runs the **text** path. It exercises the prompt, the model and the > knowledge base, and it reports what it could not prove — a disabled agent, tools > its channels will not call, flows it did not evaluate. Treat those warnings as > part of the result. ## Agents and senders A **sender** is the identity a conversation happens on: a phone number, a WhatsApp account, an email address. ```mermaid theme={null} flowchart LR A["Agent"] --- S1["Sender
+1 415 555 0100"] A --- S2["Sender
support@acme.com"] A --- S3["Sender
WhatsApp"] ``` An agent can answer on several senders. **A sender answers with at most one agent** — connecting one that is already in use is rejected, naming the agent that holds it. ```bash theme={null} npx zavudev agents list npx zavudev agents senders connect --agent --sender ``` An agent can also exist with no sender at all while you build it. It will not receive anything until you connect one and enable it. ## Addressing an agent Everything that hangs off an agent (its tools, flows and knowledge bases) is reachable two ways: ``` /v1/senders/{senderId}/agent/tools through the sender that answers with it /v1/agents/{agentId}/tools through the agent itself ``` They are the same resource. Use the second when the agent has no sender yet, which is the state `POST /v1/agents` creates and the one you assemble an agent in. The first is convenient when you are already holding a sender id. ```sh theme={null} # Build an agent before it answers anywhere AGENT=$(curl -s -X POST https://api.zavu.dev/v1/agents \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -d '{"name":"Ada","provider":"zavu","model":"openai/gpt-5.6-luna","systemPrompt":"..."}' \ | jq -r .agent.id) curl -X POST https://api.zavu.dev/v1/agents/$AGENT/tools -d '{...}' curl -X POST https://api.zavu.dev/v1/agents/$AGENT/knowledge-bases -d '{...}' # Then connect a sender and enable it curl -X POST https://api.zavu.dev/v1/agents/$AGENT/senders -d "{\"senderId\":\"$SENDER_ID\"}" curl -X PATCH https://api.zavu.dev/v1/agents/$AGENT -d '{"enabled":true}' ``` ## Where to go next > [card] [Functions quickstart](/guides/functions/quickstart) > > Write and deploy your first agent in code. > [card] [Factory agents](/guides/functions/factory-agents) > > Start from a production agent instead of a blank file. > [card] [Voice agents](/guides/voice-agents/overview) > > Answer and place phone calls. > [card] [Agent setup](/guides/ai-agents/setup) > > Create and configure an agent without writing code. # AI Agents Source: https://docs.zavu.dev/concepts/ai-agents Automate conversations with AI-powered agents that respond to incoming WhatsApp, SMS and Email messages. ## AI Agents An **AI Agent** is an automated responder attached to a sender that uses large language models (LLMs) to handle incoming messages. When a customer sends a message, the agent processes it and generates an intelligent response. ## What is an AI Agent? Think of an AI Agent as a virtual assistant for your messaging. It can: * **Answer customer questions** using your knowledge base * **Collect information** through conversational flows * **Execute actions** via webhook integrations * **Escalate to humans** when needed ```json Example Agent Configuration theme={null} { "id": "agent_abc123", "name": "Support Agent", "enabled": true, "provider": "openai", "model": "gpt-4o-mini", "systemPrompt": "You are a helpful customer support agent for Acme Corp...", "contextWindowMessages": 10, "triggerOnChannels": ["sms", "whatsapp"], "stats": { "totalInvocations": 1250, "totalTokensUsed": 450000, "totalCost": 0.85 } } ``` ## How Agents Work When a message arrives, the agent processes it through several stages: ``` Inbound Message (SMS/WhatsApp) | v Agent Enabled? | +---+---+ | | No Yes | | v v Webhook Check Active Flow Only | +---+---+ | | Has Flow No Flow | | v v Continue Check KB Flow for Context | | v v Execute Generate Step Response | | +---+---+ | v Send Response ``` ### Processing Flow 1. **Check for active flow**: If the contact is in a conversational flow, continue that flow 2. **Match flow triggers**: Check if the message should start a new flow (`keyword`, `always`, and `intent` — the last judged by a classifier rather than by matching words; see [Flows](/guides/ai-agents/flows)) 3. **Retrieve context**: Get conversation history and relevant knowledge base chunks 4. **Generate response**: Call the LLM with context and system prompt 5. **Send response**: Deliver the response via the same channel ## Key Components ### LLM Configuration Configure which AI model powers your agent: | Setting | Description | | - | - | | **Provider** | OpenAI, Anthropic, Google, Mistral, or Zavu (managed) | | **Model** | The specific model (e.g., `gpt-4o-mini`, `claude-3-5-sonnet`) | | **System Prompt** | Instructions that define the agent's personality and behavior | | **Temperature** | Creativity level (0 = deterministic, 2 = creative) | | **Max Tokens** | Maximum response length | | **Context Window** | Number of previous messages to include | ### Flows **Flows** are deterministic conversation paths for structured interactions like lead capture or appointment booking. They combine fixed messages, data collection, and AI-generated responses. ``` [Welcome] → [Ask Name] → [Ask Email] → [Confirm] → [End] ``` Flows are useful when you need: * Guaranteed data collection (name, email, phone) * Consistent messaging for compliance * Step-by-step processes (booking, ordering) ### Tools **Tools** allow your agent to execute actions by calling your webhooks. The LLM can decide when to use a tool based on the conversation. | Example Tool | Use Case | | - | - | | `check_order_status` | Look up order tracking info | | `create_ticket` | Open a support ticket | | `book_appointment` | Schedule a calendar event | | `get_inventory` | Check product availability | ### Knowledge Base A **Knowledge Base** stores documents that the agent can reference when answering questions. Using RAG (Retrieval Augmented Generation), the agent searches for relevant content and includes it in its context. * Upload FAQs, product docs, policies * Automatic chunking and embedding * Semantic search for relevant context ## Pricing AI Agent costs are **pass-through** with no markup. You pay exactly what the AI providers charge: | Provider | Model | Input | Output | \~Cost/Message | | - | - | - | - | - | | OpenAI | gpt-5.6-luna | \$0.20/1M | \$1.20/1M | \~\$0.0004 | | OpenAI | gpt-4o-mini | \$0.15/1M | \$0.60/1M | \~\$0.0002 | | OpenAI | gpt-4o | \$2.50/1M | \$10/1M | \~\$0.004 | | Anthropic | claude-3-5-haiku | \$0.80/1M | \$4/1M | \~\$0.0014 | | Anthropic | claude-3-5-sonnet | \$3/1M | \$15/1M | \~\$0.005 | | Google | gemini-2.5-flash-lite | \$0.10/1M | \$0.40/1M | \~\$0.0002 | | Google | gemini-2.5-flash | \$0.30/1M | \$2.50/1M | \~\$0.0007 | `~Cost/Message` assumes roughly 1,000 input and 150 output tokens. Provider prices move: the agent's model picker in the dashboard lists the catalog currently in force, and is what billing charges against. > [!NOTE] > > If you use the **Zavu** provider, we handle the API key and charge your account balance directly. ## Best Practices > [card] Write Clear Prompts > > Your system prompt defines the agent's behavior. Be specific about tone, limitations, and when to escalate. > [card] Use Flows for Structure > > For data collection or multi-step processes, use flows instead of relying on the LLM to remember steps. > [card] Add Knowledge Base > > Upload FAQs and product docs so the agent can provide accurate, specific answers. > [card] Monitor Costs > > Check execution logs regularly. Use smaller models like gpt-4o-mini for cost efficiency. ## Next Steps > [card] [Setup Guide](/guides/ai-agents/setup) > > Configure your first AI Agent > [card] [Flows Guide](/guides/ai-agents/flows) > > Build conversational flows > [card] [Tools Guide](/guides/ai-agents/tools) > > Add webhook integrations > [card] [Knowledge Base](/guides/ai-agents/knowledge-base) > > Set up RAG with your documents # Data Retention Source: https://docs.zavu.dev/concepts/data-retention Understanding Zavu data retention policies. Default retention windows and GDPR-compliant deletion. ## Data Retention Zavu implements configurable data retention policies to help you manage storage and comply with data protection regulations like GDPR. ## Default Retention Period | Data Type | Default Retention | Maximum Retention | | - | - | - | | Messages | 90 days | 180 days | | Conversations | 90 days | 180 days | | Webhook Deliveries | 90 days | 180 days | | Agent Executions | 90 days | 180 days | | Event log (activities) | 90 days | 180 days | | Voice calls and transcripts | 90 days | 180 days | The minimum is 90 days on every plan. API logs are kept for 30 days, separately. A conversation is deleted the configured number of days after its **last** message, so a thread in use is not deleted mid-conversation. Every other record is deleted that many days after it was created. > [!WARNING] > > A new retention period applies to data saved from that moment on. Records already stored keep the deletion date they were given when they were saved: lengthening the period does not extend them, and shortening it does not delete anything earlier. Expired data is removed by a cleanup that runs every hour and keeps going until everything expired is deleted; with a lot of volume it can take a few hours. Files a message points to (media, attachments, call recordings) are not yet deleted with the message. ## Configuring Retention You can configure the retention period for your project through the [Dashboard](https://dashboard.zavu.dev/data-retention). ### Dashboard 1. Open **Data & retention** from the account menu (in the classic dashboard, under **Compliance**) 2. Choose the retention period (90 to 180 days) 3. Click **Save changes** and confirm ### What's NOT Affected The following data types are excluded from retention policies due to legal requirements: * **Billing transactions** - Required for financial records (7 years) * **Usage and billing records** - Required for billing reconciliation * **API Keys** - Not subject to automatic deletion * **Contacts** - Customer data managed separately ## Exporting Your Data Before data is deleted, you can export it for your records from the Zavu [Dashboard](https://dashboard.zavu.dev/data-retention). An export is one JSON file with all the history still kept for each type you pick (messages, conversations, webhook deliveries, agent runs, the event log, and calls with their transcripts), available for 24 hours. Contacts and templates are not included. For programmatic exports, contact our support team. ## GDPR Compliance Zavu's data retention features help you comply with GDPR requirements: * **Right to erasure**: Data is automatically deleted after the retention period, and a contact can be deleted at any time with `DELETE /v1/contacts/{contactId}` (the contact and its channels go at once; its conversations and messages stay, without the contact, until their deletion date) * **Data portability**: Export your data from the Dashboard, or contact support for a machine-readable export * **Storage limitation**: Configure retention periods appropriate for your use case * **Transparency**: See, for each type of data, when your oldest record will be deleted > [!NOTE] > > To delete a specific person, delete their contact from the dashboard or with `DELETE /v1/contacts/{contactId}`: the contact and its channels are removed at once; their conversations and messages stay, no longer linked to the contact, until their deletion date. For anything else, contact our support team. ## Best Practices 1. **Export what you need**: Export important data before it reaches its deletion date 2. **Regular backups**: Keep periodic backups of data you need to retain long-term 3. **Right-size retention**: Configure a retention window that matches your compliance and operational needs ## Related * [Dashboard: Data Retention](https://dashboard.zavu.dev/data-retention) # Functions Source: https://docs.zavu.dev/concepts/functions Serverless TypeScript that runs on Zavu Cloud and powers your agents — write code, run npx zavudev deploy, your AI agent has new tools. ## Functions A **Zavu Function** is a serverless TypeScript snippet that runs in Zavu Cloud and reacts to messaging events or AI agent tool calls. You write the code locally, run `npx zavudev deploy`, and seconds later your agent is live with tools backed by real business logic — no servers, no Docker, no webhook receivers to maintain. ## What is a Zavu Function? Think of a Function as the code-side counterpart to an AI Agent. Where an Agent decides *what* to say, a Function executes the actions the Agent needs — query a database, check inventory, book a reservation, call your own API. * **Declare an agent in code** with `defineAgent` — config stays in sync with your repo. * **Declare tools** with `defineTool` — handlers run inside Zavu Cloud when the LLM decides to call them. * **React to events** with `defineFunction` — `message.inbound`, `broadcast.status_changed`, etc. * **Auto-provisioned API key** — the function can call any Zavu API endpoint without you handling credentials. ```ts Example: restaurant booking agent theme={null} import { defineAgent, defineTool } from "@zavudev/functions" defineAgent({ senderId: process.env.SENDER_ID!, name: "Bella", provider: "zavu", model: "openai/gpt-5.6-luna", prompt: "You are Bella, host at La Pizzeria. Be brief.", channels: ["whatsapp"], }) defineTool({ name: "check_availability", description: "Get free reservation slots for a date and party size.", parameters: { type: "object", properties: { date: { type: "string" }, partySize: { type: "number" }, }, required: ["date", "partySize"], }, handler: async ({ date, partySize }) => { // your real booking logic here return { available: true, slots: ["19:00", "21:00"] } }, }) ``` That's the entire integration. One file, `npx zavudev deploy`, done. ## How it relates to AI Agents | Need | Use | | - | - | | A no-code agent configured from the dashboard | [AI Agents](/concepts/ai-agents) | | An agent with custom business logic in your own code | **Zavu Functions** | | Both — start no-code, evolve to code | Start with AI Agents, migrate when you need real tools or custom flows | Functions can fully manage an AI Agent: the `defineAgent` call creates and keeps the agent config in sync with your code on every deploy. The dashboard surfaces "managed by function" on these agents and disables manual edits to prevent drift, except the model settings: a deploy replaces those only when the code changed them. ## Mental model ``` +----------------+ +--------------------+ +-----------+ | WhatsApp user | -----> | Zavu sender (WABA) | -----> | AI Agent | +----------------+ +--------------------+ +-----------+ | (tool call) v +-------------------------------+ | Your Zavu Function | | defineTool(handler) | +-------------------------------+ | (return result) v Agent answers user ``` * **Fully managed runtime.** Each function runs in Zavu's serverless cloud. No consoles to log into — `npx zavudev deploy` handles bundling, dependencies, and publishing. * **Internal invocation.** When the agent calls a tool, we use signed internal invocations — your function is **not** publicly exposed. No HTTP, no HMAC secrets to rotate, no DDoS surface. * **Native event binding.** Functions can also subscribe to Zavu events (`message.inbound`, `broadcast.status_changed`, etc.) via triggers. * **Auto-provisioned credentials.** Every function gets a scoped `ZAVU_API_KEY` in its environment so it can call our SDK without you handling key distribution. ## Lifecycle 1. **`npx zavudev fn init`** — scaffolds a project with `index.ts`, `package.json`, and a `.zavu/` config that links the local directory to a Function record in your Zavu project. 2. **`npx zavudev fn secrets set KEY value`** — encrypts and stores secrets that will be injected as env vars at runtime. 3. **`npx zavudev deploy`** — bundles your code + dependencies, ships them to Zavu Cloud, syncs the agent + tool declarations from your code into your project, and starts serving traffic. The dashboard reflects the new state immediately. 4. **`npx zavudev fn logs --tail`** — streams runtime logs from invocations as they happen. 5. **`npx zavudev fn versions`** — list deploys and roll back to any previous one. ## Pricing model Functions are metered by **invocation units**. One unit equals one invocation of a 128 MB function on **arm64** (the default CPU architecture). Two multipliers compound: ### Memory multiplier Higher memory configs cost proportionally more units per invocation: | Memory | Units per call | | - | - | | 128 MB | 1× | | 256 MB | 2× | | 512 MB | 4× | | 1024 MB | 8× | ### CPU architecture multiplier Functions default to **arm64** (AWS Graviton — \~20% cheaper compute). Switch to **x86\_64** only when an npm dependency requires it (native modules without arm64 prebuilds). x86 carries a 25% premium to cover the higher AWS Lambda pricing. | Architecture | Cost multiplier | When to use | | - | - | - | | `arm64` (default) | 1.00× | Default. Works for all pure-TypeScript code and most native modules with modern prebuilds. | | `x86_64` | 1.25× | When a critical npm package only ships x86 prebuilts (e.g. some image / crypto libs). | A 256 MB function on x86\_64 costs `2 × 1.25 = 2.5 units` per invocation. Set the architecture via: * **`package.json`**: `{ "zavu": { "architecture": "x86_64" } }` (versioned in git) * **CLI flag**: `npx zavudev deploy --arch x86_64` * **Dashboard**: Function detail → Settings → CPU architecture The change takes effect on the next deploy. ### Plan quotas Each paid plan includes a generous monthly quota; overage is billed automatically on your next invoice via Stripe Meters. | Plan | Included units | Overage | | - | - | - | | Free | 300k | Hard cap — invocations blocked when reached | | Hobby | 1M | \$5 / 1M units | | Standard | 5M | \$4 / 1M units | | Growth | 10M | \$3 / 1M units | ### Examples | Setup | Invocations | Units consumed | Plan: overage at Hobby | | - | - | - | - | | 128 MB arm64 | 1.5M | 1.5M | \$2.50 | | 128 MB x86\_64 | 1.5M | 1.875M | \$4.38 | | 256 MB arm64 | 1.5M | 3M | \$10.00 | | 256 MB x86\_64 | 1.5M | 3.75M | \$13.75 | Cold start, network call, error — every invocation counts the same. The unit model is intentionally aligned with the underlying compute cost so you can predict overage from your code's memory + architecture profile. ## Generate Functions code with AI Install [Zavu's Coding Agent Skills](/tools/coding-agent-skills) in your IDE (Claude Code, Cursor, Copilot, and 40+ others) and your AI assistant will know the full `defineAgent` / `defineTool` API, the `zavu` CLI, and the debugging flow. Describe what you want — your agent writes the code. ```sh theme={null} npx skills add zavudev/zavu-skills ``` The `functions` skill is loaded on-demand whenever you mention Functions, tool handlers, or `npx zavudev deploy`. ## Continue * **[Quickstart](/guides/functions/quickstart)** — Full step-by-step from zero to live agent in 10 minutes. * **[CLI reference](/guides/functions/cli)** — Every `zavu` command, flag by flag. * **[Defining agents](/guides/functions/defining-agents)** — `defineAgent` API, providers, prompts, models. * **[Defining tools](/guides/functions/defining-tools)** — `defineTool` API, schemas, handlers, error handling. * **[Secrets](/guides/functions/secrets)** — Environment variables encrypted at rest. * **[Triggers](/guides/functions/triggers)** — Listen to Zavu events from your function. * **[Runtime versions](/guides/functions/runtime)** — How pinning works, when to upgrade. * **[Advanced patterns](/guides/functions/advanced-patterns)** — Persistent state, observability, retries, costs. * **[Examples](/guides/functions/examples/restaurant)** — Complete walkthroughs. # Partner Invitations Source: https://docs.zavu.dev/concepts/invitations Onboard clients to connect their WhatsApp Business account or Facebook Page to your Zavu project via partner invitations. Partner invitations allow you to generate links that your clients can use to connect a Meta channel to your project. When a client completes the flow, a new sender is automatically created in your project. ## Channels `connectionType` picks what the client connects. One invitation connects one channel; create one invitation per channel to onboard a client on several. | `connectionType` | What the client connects | | - | - | | `whatsapp_waba` (default) | An official WhatsApp Business Account, through Meta's embedded signup | | `messenger` | A Facebook Page they administer. Its Messenger inbox, including Marketplace chats, is routed to Zavu | > [!NOTE] > > A Facebook Page can only be connected to one Zavu project at a time. If your client picks a Page that another project already connected, the newer connection wins and the older one is disconnected. ## Phone Number Options These apply to `whatsapp_waba` only. A Facebook Page has no phone number, so sending `phoneNumberId` or `allowedPhoneCountries` with `connectionType: "messenger"` returns `400`. 1. **Client provides their own number**: The client uses their existing phone number to register for WhatsApp Business 2. **Pre-assign a Zavu number**: You purchase a phone number from Zavu and assign it to the invitation. The client registers this number under their WhatsApp Business Account ## Use Cases * **Agencies**: Onboard client WhatsApp accounts without accessing their Facebook credentials * **SaaS platforms**: Let customers self-service WhatsApp setup * **Resellers**: Manage multiple client WhatsApp accounts under one project * **Managed services**: Provision phone numbers and let clients complete the WhatsApp verification ## How It Works ```mermaid theme={null} sequenceDiagram participant Partner as Your App participant Zavu as Zavu API participant Client as Client participant Meta as Meta Partner->>Zavu: POST /v1/invitations Zavu-->>Partner: { url, token } Partner->>Client: Share invitation URL Client->>Zavu: Open invitation page Client->>Meta: Complete embedded signup Meta-->>Zavu: OAuth callback Zavu->>Zavu: Create sender Client-->>Partner: WhatsApp connected! ``` ## Create an Invitation ### Client provides their own number ```bash theme={null} curl -X POST https://api.zavu.dev/v1/invitations \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "clientName": "Acme Corp", "clientEmail": "contact@acme.com", "expiresInDays": 14 }' ``` ### Pre-assign a Zavu phone number First, purchase a phone number from Zavu, then create the invitation with the `phoneNumberId`: ```bash theme={null} curl -X POST https://api.zavu.dev/v1/invitations \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "clientName": "Acme Corp", "clientEmail": "contact@acme.com", "phoneNumberId": "pn_abc123", "expiresInDays": 14 }' ``` When a phone number is pre-assigned, the client will see the number on the invitation page and use it during the WhatsApp registration. The verification code will be received via phone call to that number. ### Connect a Facebook Page (Messenger) ```bash theme={null} curl -X POST https://api.zavu.dev/v1/invitations \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "clientName": "Acme Corp", "clientEmail": "contact@acme.com", "connectionType": "messenger" }' ``` The client authorizes with Facebook, picks a Page they administer, and the resulting sender lands in your project with the Messenger channel enabled. ### Response ```json theme={null} { "invitation": { "id": "jh7am5bng9p3v2x1k4r8", "url": "https://dashboard.zavu.dev/invite/abc123xyz", "token": "abc123xyz", "clientName": "Acme Corp", "clientEmail": "contact@acme.com", "clientPhone": null, "phoneNumberId": null, "connectionType": "whatsapp_waba", "status": "pending", "senderId": null, "connectedAccount": null, "failureReason": null, "failedAt": null, "expiresAt": "2025-01-15T00:00:00.000Z", "viewedAt": null, "startedAt": null, "completedAt": null, "createdAt": "2025-01-01T12:00:00.000Z", "updatedAt": "2025-01-01T12:00:00.000Z" } } ``` Share the `url` with your client. They complete Meta's authorization to connect the channel. Once done, `status` is `completed`, `senderId` points at the new sender, and `connectedAccount` reports what was linked: the WhatsApp number or the Facebook Page. ## Invitation Lifecycle | Status | Description | | - | - | | `pending` | Invitation created, waiting for client | | `in_progress` | Client started the signup flow | | `completed` | WhatsApp connected, sender created | | `expired` | Invitation expired before completion | | `cancelled` | Invitation was cancelled | | `failed` | The client started and did not finish. `failureReason` says why, and the same link can be retried | ## List Invitations ```bash theme={null} curl https://api.zavu.dev/v1/invitations \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` Filter by status: ```bash theme={null} curl "https://api.zavu.dev/v1/invitations?status=completed" \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` ## Get Invitation Details ```bash theme={null} curl https://api.zavu.dev/v1/invitations/jh7am5bng9p3v2x1k4r8 \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` ## Cancel an Invitation Cancel an active invitation to prevent the client from using it: ```bash theme={null} curl -X POST https://api.zavu.dev/v1/invitations/jh7am5bng9p3v2x1k4r8/cancel \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` > [!NOTE] > > You cannot cancel a completed invitation. Once a sender is created, you can delete it through the senders API. ## After Completion When a client completes the signup flow: 1. A new **sender** is created in your project with WhatsApp enabled 2. The sender's phone number is the client's registered WhatsApp number 3. The invitation status changes to `completed` 4. The `senderId` field contains the ID of the created sender You can then use this sender to send WhatsApp messages on behalf of your client: ```bash theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Zavu-Sender: snd_abc123" \ -H "Content-Type: application/json" \ -d '{ "to": "+14155551234", "channel": "whatsapp", "messageType": "template", "content": { "templateId": "tmpl_xyz789", "templateVariables": { "1": "John" } } }' ``` ## SDK Examples ```typescript TypeScript theme={null} import Zavudev from '@zavudev/sdk'; const zavu = new Zavudev({ apiKey: process.env["ZAVUDEV_API_KEY"], }); // Create invitation const invitation = await zavu.invitations.create({ clientName: 'Acme Corp', clientEmail: 'contact@acme.com', expiresInDays: 14 }); console.log('Share this URL:', invitation.url); // List invitations const { items } = await zavu.invitations.list({ status: 'pending' }); // Cancel invitation await zavu.invitations.cancel(invitation.id); ``` ```ruby Ruby theme={null} require 'zavudev' zavu = Zavudev::Client.new( api_key: ENV['ZAVUDEV_API_KEY'] ) # Create invitation invitation = zavu.invitations.create( client_name: "Acme Corp", client_email: "contact@acme.com", expires_in_days: 14 ) puts "Share this URL: #{invitation.url}" # List invitations result = zavu.invitations.list(status: "pending") # Cancel invitation zavu.invitations.cancel(invitation.id) ``` ```php PHP theme={null} $zavu = new Zavudev\Client(['apiKey' => getenv('ZAVUDEV_API_KEY')]); // Create invitation $invitation = $zavu->invitations->create([ 'clientName' => 'Acme Corp', 'clientEmail' => 'contact@acme.com', 'expiresInDays' => 14, ]); echo "Share this URL: " . $invitation->url . "\n"; // List invitations $result = $zavu->invitations->list(['status' => 'pending']); // Cancel invitation $zavu->invitations->cancel($invitation->id); ``` ## Best Practices 1. **Set appropriate expiration**: Use shorter expirations (3-7 days) for urgent onboarding 2. **Track client info**: Include `clientName` and `clientEmail` for easier management 3. **Monitor status**: Check for `expired` or `cancelled` invitations and resend if needed 4. **Webhooks**: Configure webhooks on the resulting sender to receive inbound messages # Phone Numbers Source: https://docs.zavu.dev/concepts/phone-numbers Understanding phone number formats, validation and introspection in Zavu. E.164 normalization, carrier lookup, capability detection for SMS, WhatsApp ## Phone Numbers Phone numbers are the primary identifier for messaging recipients in Zavu. Understanding how phone numbers work will help you build reliable messaging applications. ## E.164 Format All phone numbers in Zavu must be in **E.164 format**. This is the international standard that ensures phone numbers are globally unique and unambiguous. ### Format Rules ``` +[country code][subscriber number] ``` * Starts with `+` sign * Country code (1-3 digits) * Subscriber number (up to 14 digits) * No spaces, dashes, or parentheses ### Examples | Country | E.164 Format | Notes | | - | - | - | | United States | `+14155551234` | Country code: 1 | | United Kingdom | `+447700900123` | Country code: 44 | | Chile | `+56912345678` | Country code: 56 | | Germany | `+4915123456789` | Country code: 49 | > [!WARNING] > > Always store and send phone numbers in E.164 format. Zavu will reject numbers that don't match this format. ## Phone Number Introspection Before sending a message, you can validate and get information about a phone number using the introspection endpoint. ```typescript TypeScript theme={null} const response = await zavu.introspect.validatePhone({ phoneNumber: "+14155551234" }); ``` ```python Python theme={null} response = zavu.introspect.validate_phone( phone_number="+14155551234" ) ``` ```ruby Ruby theme={null} response = zavu.introspect.validate_phone( phone_number: "+14155551234" ) ``` ```go Go theme={null} response, err := client.Introspect.Phone(context.TODO(), zavudev.IntrospectPhoneParams{ PhoneNumber: zavudev.String("+14155551234"), }) ``` ```php PHP theme={null} $response = $client->introspect->phone([ 'phoneNumber' => '+14155551234', ]); ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/introspect/phone \ -H "Authorization: Bearer zv_live_xxx" \ -H "Content-Type: application/json" \ -d '{ "phoneNumber": "+14155551234" }' ``` ### Response ```json theme={null} { "phoneNumber": "+14155551234", "validNumber": true, "countryCode": "US", "nationalFormat": "(415) 555-1234", "lineType": "mobile", "carrier": { "name": "Verizon Wireless", "type": "mobile" }, "availableChannels": ["sms", "whatsapp"] } ``` ### Response Fields | Field | Description | | - | - | | `phoneNumber` | The E.164 formatted number | | `validNumber` | Whether the number is valid | | `countryCode` | ISO country code (e.g., "US", "CL") | | `nationalFormat` | Number in local format | | `lineType` | Type of line: `mobile`, `landline`, `voip`, `toll_free`, `unknown` | | `carrier` | Carrier information if available | | `availableChannels` | Channels that can reach this number (based on line type) | ### Available Channels by Line Type The `availableChannels` array is determined automatically based on the line type: | Line Type | SMS | WhatsApp | Explanation | | - | - | - | - | | `mobile` | Yes | Yes | Mobile numbers support all messaging channels | | `voip` | Yes | No | VoIP numbers (Google Voice, Skype) can receive SMS but not WhatsApp | | `toll_free` | Yes | No | Toll-free numbers support SMS only | | `landline` | No | No | Landlines cannot receive text messages | | `unknown` | Yes | No | When line type is uncertain, we default to SMS only | > [!NOTE] > > If `availableChannels` is empty, the number cannot receive any messages. This typically happens with landline numbers. > [!TIP] > > Use introspection to validate numbers before adding them to your database, or to determine which channels are available for a contact. ## Contacts When you send a message to a phone number, Zavu automatically creates a **Contact** record. Contacts track: * **Channel metrics**: Success/failure rates per channel * **Channel availability**: Which channels can reach this contact * **Last inbound**: When the contact last messaged you (important for WhatsApp) * **Preferences**: Default channel preference ### Contact Lifecycle ``` First message sent to +14155551234 | v Contact automatically created | v Delivery callbacks update metrics | v Future routing decisions use this data ``` ### Retrieving Contact Information ```typescript TypeScript theme={null} const contact = await zavu.contacts.retrieveByPhone({ phoneNumber: "+14155551234" }); ``` ```python Python theme={null} contact = zavu.contacts.retrieve_by_phone( phone_number="+14155551234" ) ``` ```ruby Ruby theme={null} contact = zavu.contacts.retrieve_by_phone( phone_number: "+14155551234" ) ``` ```go Go theme={null} contact, err := client.Contacts.GetByPhone(context.TODO(), "+14155551234") ``` ```php PHP theme={null} $contact = $client->contacts->retrieveByPhone('+14155551234'); ``` ```bash cURL theme={null} curl https://api.zavu.dev/v1/contacts/phone/+14155551234 \ -H "Authorization: Bearer zv_live_xxx" ``` ### Contact Response ```json theme={null} { "id": "contact_abc123", "phoneNumber": "+14155551234", "countryCode": "US", "availableChannels": ["sms", "whatsapp"], "defaultChannel": "whatsapp", "verified": true, "metadata": { "source": "checkout" }, "createdAt": "2025-01-15T10:30:00Z" } ``` ## Country Detection Zavu automatically detects the country from the phone number's dial code. This is used for: * **Routing decisions**: Some channels work better in certain regions * **Cost optimization**: Channel costs vary by country * **Compliance**: Ensuring messages comply with local regulations ### Supported Regions Zavu supports phone numbers from all countries. The country code is extracted from the E.164 number automatically. ## Best Practices > [card] Validate Early > > Use the introspection API when collecting phone numbers to catch errors early. > [card] Store E.164 > > Always store phone numbers in E.164 format in your database. > [card] Handle Line Types > > Some channels only work with mobile numbers. Check `lineType` if channel selection matters. > [card] Respect Preferences > > Let users set their preferred channel via the contact's `defaultChannel` field. ## Next Steps > [card] [Senders](/concepts/senders) > > Learn how senders work with phone numbers > [card] [Send a Message](/guides/sending-messages/easy-way) > > Start sending messages to phone numbers # Rate Limiting Source: https://docs.zavu.dev/concepts/rate-limiting Per-project request limits on the Zavu API: the 600 requests per minute default, how it rises with verification, 429 responses, Retry-After, and how it differs from sending limits ## Rate Limiting Every authenticated request to a `/v1/*` or `/v2/*` endpoint counts against a per-project request limit. Requests to both versions share one window. It protects the platform from bursts; it is not a sending quota. The daily ceilings per channel, the per-recipient SMS cooldown and the monthly allowances are separate rules with separate error codes. They are covered in [Sending limits](/concepts/sending-limits) and summarized at the end of this page. ## Request Limits | Limit | Who gets it | How it is set | | - | - | - | | **600 requests/min** | Every project | The default | | **1,200 requests/min** | Every project in a team whose Business Verification (KYB) is approved | Applied automatically when the verification is approved | | **2,400 requests/min** | Every project in a team whose 10DLC brand has been verified by the registry | Applied automatically when the brand is verified | A limit that Zavu set for your account by hand is never lowered by these automatic steps, and neither is a limit that is already higher. > [!NOTE] > > Counting is per project: each project has its own window and its own headers. The tier itself is set for every project in the team at once. ### How to Increase Your Limit **1. Complete Business Verification (KYB)** In the dashboard, go to **Compliance** and open **Business verification**. When it is approved, every project in the team moves to **1,200 requests/min**. **2. Register a 10DLC brand (US SMS)** Register your brand for 10DLC. When the registry verifies it, every project in the team moves to **2,400 requests/min**. The campaign is not what raises the request limit; it is what lets you send SMS to US numbers. > [!TIP] > > The increase is applied the moment the verification is approved. Nothing changes in your code: the new limit shows up in `X-RateLimit-Limit` on your next request. ## Rate Limit Headers Every `/v1/*` response includes headers to help you track your usage: | Header | Description | Example | | - | - | - | | `X-RateLimit-Limit` | Maximum requests per minute for this project | `600` | | `X-RateLimit-Remaining` | Requests left in the current window | `542` | | `X-RateLimit-Reset` | Unix timestamp (ms) when the current window ends | `1701234600000` | | `Retry-After` | Seconds to wait (only on 429) | `45` | ## Checking Rate Limits with the SDK Call `.withResponse()` on the request to get the parsed result together with the raw HTTP response: ```typescript TypeScript theme={null} import Zavudev from '@zavudev/sdk'; const zavu = new Zavudev({ apiKey: process.env['ZAVUDEV_API_KEY'], }); const { data, response } = await zavu.messages .send({ to: "+14155551234", text: "Hello from Zavu!" }) .withResponse(); const limit = response.headers.get("X-RateLimit-Limit"); const remaining = response.headers.get("X-RateLimit-Remaining"); const reset = response.headers.get("X-RateLimit-Reset"); console.log(`Rate limit: ${remaining}/${limit} requests remaining`); console.log(`Window ends at: ${new Date(Number(reset)).toISOString()}`); console.log("Message ID:", data.message.id); ``` ```bash cURL theme={null} curl -i -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{"to": "+14155551234", "text": "Hello!"}' # Response headers will include: # X-RateLimit-Limit: 600 # X-RateLimit-Remaining: 599 # X-RateLimit-Reset: 1701234600000 ``` ## Rate Limit Exceeded (429) When a project exceeds its limit, the API returns `429 Too Many Requests` with a `Retry-After` header (in seconds) and this body: ```json theme={null} { "code": "rate_limited", "message": "Rate limit exceeded. Retry after 2024-01-15T12:30:00.000Z", "details": { "retryAfter": 1705322400000 } } ``` `details.retryAfter` is the same instant as `X-RateLimit-Reset`, in Unix milliseconds. If the rate limiter itself cannot be reached, requests are refused with this same response and a 60-second `Retry-After` rather than let through unchecked. ## Handling Rate Limits The TypeScript SDK retries a 429 on its own, up to 2 times by default, waiting for the `Retry-After` the API sent. Set `maxRetries` higher for bursty traffic; handle the error yourself only once the retries are exhausted. For the other SDKs, check the status on the API error the same way [the SDK overview](/sdks/overview#error-handling) does. ```typescript TypeScript theme={null} import Zavudev from "@zavudev/sdk"; const zavu = new Zavudev({ apiKey: process.env["ZAVUDEV_API_KEY"], maxRetries: 4, }); async function sendWithRateLimitHandling(to: string, text: string) { try { const { data, response } = await zavu.messages .send({ to, text }) .withResponse(); const remaining = Number(response.headers.get("X-RateLimit-Remaining")); if (remaining < 10) { console.warn(`Low rate limit: ${remaining} requests remaining`); } return data; } catch (error) { if (error instanceof Zavudev.RateLimitError) { // Retries are exhausted. Retry-After is in seconds. const waitMs = Number(error.headers.get("retry-after") ?? 60) * 1000; console.log(`Rate limited. Waiting ${waitMs}ms before retry...`); await new Promise((resolve) => setTimeout(resolve, waitMs)); return sendWithRateLimitHandling(to, text); } throw error; } } ``` ```python Python theme={null} import os import time from zavudev import Zavudev, APIError zavu = Zavudev( api_key=os.environ.get("ZAVUDEV_API_KEY"), ) def send_with_rate_limit_handling(to: str, text: str): try: return zavu.messages.send(to=to, text=text) except APIError as e: if e.status_code == 429: print("Rate limited. Waiting 60s before retry...") time.sleep(60) return send_with_rate_limit_handling(to, text) raise ``` ```ruby Ruby theme={null} require "zavudev" client = Zavudev::Client.new( api_key: ENV["ZAVUDEV_API_KEY"] ) def send_with_rate_limit_handling(client, to:, text:) client.messages.send_(to: to, text: text) rescue Zavudev::Errors::APIError => e raise unless e.status == 429 puts "Rate limited. Waiting 60s before retry..." sleep(60) send_with_rate_limit_handling(client, to: to, text: text) end ``` ```go Go theme={null} func sendWithRateLimitHandling(client *zavudev.Client, to, text string) (*zavudev.MessageResponse, error) { result, err := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String(to), Text: zavudev.String(text), }) if err != nil { var apiErr *zavudev.APIError if errors.As(err, &apiErr) && apiErr.StatusCode == 429 { fmt.Println("Rate limited. Waiting 60s before retry...") time.Sleep(60 * time.Second) return sendWithRateLimitHandling(client, to, text) } return nil, err } return result, nil } ``` ```php PHP theme={null} use Zavudev\APIError; function sendWithRateLimitHandling($client, string $to, string $text) { try { return $client->messages->send(['to' => $to, 'text' => $text]); } catch (APIError $e) { if ($e->status === 429) { echo "Rate limited. Waiting 60s before retry...\n"; sleep(60); return sendWithRateLimitHandling($client, $to, $text); } throw $e; } } ``` ## How It Works The limit is a **sliding window** over the last 60 seconds: at any moment, a project may have at most its limit of requests in the previous minute. There is no wall-clock reset. Capacity comes back as older requests age out of the window, so a burst that hits the limit at 12:00:30 is not cleared at 12:01:00; it clears as the requests from 12:00:30 onward fall out of the window. ``` Limit: 600 in any 60-second window ├── 12:00:00 request 1 OK (remaining: 599) ├── 12:00:20 request 600 OK (remaining: 0) ├── 12:00:21 request 601 429 rate_limited └── 12:01:00 request 1 has aged out: one slot is back ``` `X-RateLimit-Remaining` tells you how many requests fit right now; waiting a full 60 seconds always empties the window. ## Other 429 responses that are not rate limits `POST /v1/messages` and `POST /v1/calls` also answer 429 for limits on what you send, not on how fast you call the API. They are different rules with different codes, and none of them is raised by the request limit above. | Code | What it means | Body shape | | - | - | - | | `daily_limit_exceeded` | The channel group's daily ceiling for your account level is spent. See [Sending limits](/concepts/sending-limits) | `{ code, message, details: { limit, remaining, dashboardUrl } }` | | `rate_limit_exceeded` | One SMS per recipient per minute: a second `sms` or `sms_oneway` message to the same number within 60 seconds. The message says how many seconds to wait | `{ error: { code, message } }` | | `a2p_limit_exceeded` | The Free plan's monthly allowance of 2,000 messages across WhatsApp, Telegram, Instagram and Messenger is spent, and the balance cannot pay for more (see [pay as you go](/concepts/sending-limits#pay-as-you-go)) | `{ error: { code, message, details } }` | ## Best Practices > [card] Monitor Headers > > Check `X-RateLimit-Remaining` to avoid hitting the limit unexpectedly. > [card] Honor Retry-After > > Wait the seconds in the `Retry-After` header, or until `details.retryAfter`, before retrying. > [card] Use Broadcasts > > For bulk sending, use the [Broadcasts API](/guides/broadcasts/overview) instead of individual requests. > [card] Use Idempotency Keys > > Include `idempotencyKey` in requests to safely retry without duplicates. ## Bulk Sending Example For sending many messages from your own loop, pace on the headers: ```typescript theme={null} import Zavudev from '@zavudev/sdk'; const zavu = new Zavudev({ apiKey: process.env['ZAVUDEV_API_KEY'], }); async function sendBulkMessages(messages: { to: string; text: string }[]) { const results = []; let remaining = 600; for (const msg of messages) { // If running low, wait for the window to slide if (remaining < 10) { console.log("Approaching rate limit, waiting 60 seconds..."); await new Promise(resolve => setTimeout(resolve, 60000)); remaining = 600; } const { data, response } = await zavu.messages.send(msg).withResponse(); remaining = Number(response.headers.get("X-RateLimit-Remaining")); results.push(data); } return results; } ``` > [!WARNING] > > For sending to more than 100 recipients, use the [Broadcasts API](/guides/broadcasts/overview) instead. Broadcasts send in paced batches and report progress. Each recipient still counts against your daily sending ceiling. ## Summary | Limit | How to get it | | - | - | | 600/min | Every project, by default | | 1,200/min | Business Verification (KYB) approved | | 2,400/min | 10DLC brand verified by the registry | ## Next Steps > [card] [Sending Limits](/concepts/sending-limits) > > Daily ceilings and destination rules per account level > [card] [Broadcasts API](/guides/broadcasts/overview) > > Send to many recipients efficiently # Senders — Smart Routing & Fallback Source: https://docs.zavu.dev/concepts/senders Senders combine phone numbers, WhatsApp Business accounts and webhooks into routing identities. Smart routing and automatic SMS/WhatsApp fallback included. ## Senders A **Sender** is a messaging profile that represents "who" is sending the message. Senders can be configured with multiple channels — SMS, WhatsApp, Email, Telegram, Instagram, and Voice — and Zavu intelligently routes messages through them. ## What is a Sender? Think of a sender as your messaging identity. It contains: * **Phone number** — used for SMS, WhatsApp, and [Voice](/guides/sending-messages/easy-way) messaging * **WhatsApp Business Account** — for [WhatsApp](/guides/whatsapp/overview) messaging (rich media, templates, interactive messages) * **Email domain** — for [transactional email](/guides/sending-messages/email) via Amazon SES * **Telegram bot** — for [Telegram](/guides/telegram/overview) messaging * **Instagram Business Account** — for Instagram direct messaging * **Webhooks** — real-time notifications for [inbound messages and delivery updates](/guides/receiving-messages/webhooks) * **Allowed destinations** — countries you can send to ```json Example Sender theme={null} { "id": "sender_12345", "name": "Primary Sender", "phoneNumber": "+13125551212", "isDefault": true, "webhook": { "url": "https://your-app.com/webhooks", "events": ["message.inbound", "message.delivered"], "active": true }, "whatsapp": { "phoneNumberId": "123456789", "displayPhoneNumber": "+13125551212" }, "emailReceivingEnabled": false, "createdAt": "2025-01-15T10:00:00Z", "updatedAt": "2025-01-15T10:00:00Z" } ``` ## Channels Zavu supports 6 messaging channels. Each has different requirements and routing behavior: | Channel | Identifier | Smart Routing | Fallback | Setup Guide | | - | - | - | - | - | | **SMS** | Phone number (E.164) | Yes | Yes | [SMS Guide](/guides/sending-messages/sms) | | **WhatsApp** | Phone number (E.164) | Yes | Yes | [WhatsApp Guide](/guides/whatsapp/overview) | | **Email** | Email address | No | No | [Email Guide](/guides/sending-messages/email) | | **Telegram** | Telegram chat ID | No | No | [Telegram Guide](/guides/telegram/overview) | | **Instagram** | Instagram-scoped ID | No | No | — | | **Voice** | Phone number (E.164) | No | No | [Sending Messages](/guides/sending-messages/easy-way) | > [!NOTE] > > **Smart routing and automatic fallback only apply to SMS and WhatsApp.** Email, Telegram, Instagram, and Voice are standalone channels that must be explicitly specified with the `channel` parameter. ## Default Sender Every project has a **default sender**. When you send a message without specifying a sender, Zavu uses the default. ```bash theme={null} # Uses default sender automatically curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer zv_live_xxx" \ -d '{"to": "+14155551234", "text": "Hello!"}' ``` To use a specific sender, include the `Zavu-Sender` header: ```bash theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer zv_live_xxx" \ -H "Zavu-Sender: sender_67890" \ -d '{"to": "+14155551234", "text": "Hello!"}' ``` ## Smart Routing Zavu's smart routing system automatically selects the **optimal channel** for each message based on cost, deliverability, and contact history. Smart routing applies only to SMS and WhatsApp. > [!NOTE] > > **Smart routing only applies to outbound messages.** When replying to an inbound message, Zavu always responds on the **same channel** the contact used. ### When Smart Routing Applies | Message Type | Routing Behavior | | - | - | | **Outbound** (you initiate) | Smart routing selects optimal channel | | **Inbound reply** (direct response) | Always use the contact's channel | ### How It Works For outbound messages, the router checks if the contact has previously messaged you. If they have, this affects channel availability: ``` Has contact messaged via WhatsApp in last 24h? | +--+--+ | | Yes No | | v v WhatsApp WhatsApp only available with template (preferred) ``` Since WhatsApp is cheaper than SMS, the router **prefers WhatsApp when the 24-hour window is open**. This means contacts who have messaged you before will typically receive messages via WhatsApp. ### Channel Selection Algorithm The router evaluates channels in this order: 1. **Filter viable channels**: Remove channels that can't deliver (e.g., WhatsApp without open window) 2. **Sort by cost**: Cheapest channels first 3. **Check success rate**: Channel must have at least 80% success rate (or fewer than 3 attempts for exploration) 4. **Select best option**: First channel meeting all criteria ### Cost Optimization | Channel | Approximate Cost | | - | - | | WhatsApp | \~\$0.01/message | | SMS | \~\$0.05/message | Smart routing can reduce messaging costs by **up to 80%** by preferring WhatsApp when available. ### WhatsApp 24-Hour Window WhatsApp has a strict rule: you can only send free-form messages within **24 hours** of the last message received from the contact. ``` Contact sends message ... 24-hour window opens | Within 24h: Send any message type | After 24h: Only template messages allowed ``` > [!WARNING] > > If the 24-hour window is closed and you don't have a [template](/concepts/templates) configured, the smart router will automatically select SMS instead of failing. ### Channel Metrics Zavu tracks delivery metrics for each [contact](/guides/contacts/overview) and channel: ```json theme={null} { "channelMetrics": { "sms": { "successCount": 45, "failureCount": 2, "avgDeliveryTimeMs": 3200, "lastSuccessAt": "2025-01-15T10:30:00Z" }, "whatsapp": { "successCount": 120, "failureCount": 1, "avgDeliveryTimeMs": 1100, "lastSuccessAt": "2025-01-15T14:22:00Z" } } } ``` The router uses these metrics to: * Avoid channels with low success rates (below 80%) * Prefer faster channels when speed matters * Explore new channels (first 3 attempts get a chance regardless of stats) ## Automatic Fallback When a message fails on one channel, Zavu **automatically retries** on an alternate channel. Fallback works between SMS and WhatsApp only. ### Fallback Flow ``` Send via WhatsApp | v Failed? | +--+--+ | | No Yes | | v v Done Wait 5s | v Retry via SMS | v Success? | +--+--+ | | Yes No | | v v Done Mark Failed ``` ### Fallback Rules | Rule | Description | | - | - | | Max 2 attempts | Each message is tried on at most 2 channels | | 5-second delay | Wait 5 seconds before retry to avoid race conditions | | No duplicate channels | Never retry on a channel that already failed | | Sender must support both | Fallback only works if sender has both channels configured | ### Enabling/Disabling Fallback Whether fallback runs depends on how you choose the channel: | Request | Fallback | | - | - | | `channel` omitted or `"auto"` | On, unless you set `fallbackEnabled: false` | | `channel` set explicitly, e.g. `"whatsapp"` | Off, unless you set `fallbackEnabled: true` | Choosing a channel explicitly means Zavu does not switch it for you, because an SMS is billed from your balance. To keep the SMS safety net on an explicit WhatsApp send, ask for it: ```json theme={null} { "to": "+14155551234", "channel": "whatsapp", "text": "Your order has shipped.", "fallbackEnabled": true } ``` Only text and template messages fall back. Media, buttons, lists and other WhatsApp-only types never do. ## Standalone Channels Email, Telegram, Instagram, and Voice operate independently from smart routing. You must explicitly specify the `channel` parameter when using them. | Channel | Key Requirement | Docs | | - | - | - | | **Email** | A domain you own, verified in Zavu | [Email Setup](/guides/email/setup) | | **Telegram** | Bot token via BotFather | [Telegram Setup](/guides/telegram/setup) | | **Instagram** | Instagram Business Account | — | | **Voice** | Phone number with voice capability | [Sending Messages](/guides/sending-messages/easy-way) | These channels don't participate in smart routing or automatic fallback because they use different recipient identifiers and have different delivery semantics than SMS/WhatsApp. ## Rate Limiting Zavu implements fair [rate limiting](/concepts/rate-limiting) to ensure reliable delivery for all users. ### Limits by Channel | Channel | Rate Limit | Level | | - | - | - | | SMS | 10 messages/second | Global | | Email | 14 messages/second | Global | | WhatsApp | 60 messages/second | Per WABA | ### Round-Robin Fairness When multiple projects are sending messages, Zavu uses **round-robin scheduling** to ensure fairness: ``` Without round-robin: Project A: 10 messages = A1, A2, A3... A10 (1 second) Project B: 1 message = B1 waits 1 second With round-robin: A1 (0ms), B1 (100ms), A2 (200ms), A3 (300ms)... B1 only waits 100ms instead of 1 second ``` This prevents high-volume senders from blocking smaller senders. > [!NOTE] > > Messages are never dropped due to rate limiting. They're queued and sent at the next available slot. ## Managing Senders ### List Senders ```bash theme={null} curl https://api.zavu.dev/v1/senders \ -H "Authorization: Bearer zv_live_xxx" ``` ### Create Sender ```bash theme={null} curl -X POST https://api.zavu.dev/v1/senders \ -H "Authorization: Bearer zv_live_xxx" \ -d '{ "name": "Marketing Sender", "phoneNumber": "+13125559999", "setAsDefault": false }' ``` ### Update Sender ```bash theme={null} curl -X PATCH https://api.zavu.dev/v1/senders/sender_12345 \ -H "Authorization: Bearer zv_live_xxx" \ -d '{ "name": "Updated Name", "setAsDefault": true }' ``` ## Webhook Configuration Each sender can have one webhook configured to receive real-time notifications for events like incoming messages and delivery updates. See the [Webhooks guide](/guides/receiving-messages/webhooks) for payload details and security. ### Create Sender with Webhook ```bash theme={null} curl -X POST https://api.zavu.dev/v1/senders \ -H "Authorization: Bearer zv_live_xxx" \ -H "Content-Type: application/json" \ -d '{ "name": "Support", "phoneNumber": "+15551234567", "webhookUrl": "https://your-app.com/webhooks", "webhookEvents": ["message.inbound", "message.delivered", "message.failed"] }' ``` The response includes the webhook secret (only shown once): ```json theme={null} { "id": "sender_12345", "name": "Support", "webhook": { "url": "https://your-app.com/webhooks", "events": ["message.inbound", "message.delivered", "message.failed"], "secret": "whsec_abc123...", "active": true } } ``` ### Update Webhook ```bash theme={null} curl -X PATCH https://api.zavu.dev/v1/senders/sender_12345 \ -H "Authorization: Bearer zv_live_xxx" \ -d '{ "webhookUrl": "https://new-url.com/webhooks", "webhookEvents": ["message.inbound"], "webhookActive": false }' ``` ### Regenerate Webhook Secret If your webhook secret is compromised: ```bash theme={null} curl -X POST https://api.zavu.dev/v1/senders/sender_12345/webhook/secret \ -H "Authorization: Bearer zv_live_xxx" ``` ### Available Webhook Events | Event | Description | | - | - | | `message.queued` | Message created and queued for sending | | `message.sent` | Message accepted by the provider | | `message.delivered` | Message delivered to recipient | | `message.failed` | Message delivery failed | | `message.inbound` | Contact sent you a message | | `message.unsupported` | Received an unsupported message type | | `conversation.new` | First message from a new contact | | `template.status_changed` | WhatsApp template approval status changed | > [!NOTE] > > For webhook payload structure and signature verification, see the [Webhooks guide](/guides/receiving-messages/webhooks) and [Webhook Security](/guides/receiving-messages/security). ## Best Practices > [card] Configure Multiple Channels > > Set up both SMS and WhatsApp on your sender to enable smart routing and automatic fallback. > [card] Use Templates for Outreach > > When initiating conversations, use [WhatsApp templates](/concepts/templates) to avoid 24-hour window issues. > [card] Monitor Metrics > > Check your [contact](/guides/contacts/overview) metrics to understand channel performance in your region. > [card] Set Allowed Destinations > > Restrict senders to specific countries to control costs and compliance. ## Next Steps > [card] [Adding Channels](/guides/senders/adding-channels) > > Connect SMS, WhatsApp, Email, Telegram, and more to your sender > [card] [Phone Numbers](/concepts/phone-numbers) > > Purchase and manage phone numbers for SMS and voice > [card] [Templates](/concepts/templates) > > Create WhatsApp templates for outbound messaging > [card] [Smart Routing Guide](/guides/sending-messages/smart-routing) > > Deep dive into routing configuration ## API Reference Manage senders programmatically: * [List senders](/api-reference/list-senders) — fetch all senders for your project * [Create a sender](/api-reference/create-sender) — register a new messaging profile * [Get a sender](/api-reference/get-sender) — retrieve sender details by ID * [Update a sender](/api-reference/update-sender) — change profile, channels, or webhooks * [Delete a sender](/api-reference/delete-sender) — remove a sender from your project # Sending Limits Source: https://docs.zavu.dev/concepts/sending-limits How much an account can send per day, how the limits rise with verification, and what happens when a message is held or refused Every account can send on every channel, to any destination, from its first minute. Verification is not a permission to send: it raises how much you may send per day. What stands in front of an individual message is the message itself — its content, and the pattern of sends around it. ## Account levels A level decides your daily ceilings. It never decides which channels or destinations you may reach. | Level | How it is reached | What it changes | | - | - | - | | **Unverified** | A new account that has proven nothing yet | The lowest ceilings: 25 SMS, 5 calls and 100 conversational messages a day | | **Verified** | Any one of: identity verification, a saved payment method, a settled deposit, a paid plan | The ceilings below | | **Business** | Business verification (KYB) | The 10,000/day SMS ceiling, which identity verification also reaches, plus 10DLC brand and campaign registration for US and Canadian SMS | Business verification is required for **one thing**: * **10DLC.** Every `sms` and `sms_oneway` message to a +1 number needs an approved 10DLC campaign, and carriers register a campaign against a vetted legal entity. Sending to a +1 number without one returns `403` with code `ten_dlc_required`. No channel asks for KYB in order to send. One-way SMS included: it sends from the first minute, within the SMS ceiling above, and every message passes Zavu's risk review. If an account is ever required to verify its business before using one-way SMS, its sends are refused with `403 kyb_required` and `details.dashboardUrl` pointing at `/kyb`. Accounts created before 24 August 2026 start at Verified. ## Raising your ceilings Do any one of these in the dashboard. Each is self-serve: * Verify your identity under **Identity verification** (`/kyc`): an ID document and a selfie, a few minutes. * Add a payment method under **Billing**: about half a minute. Nothing is charged. * Settle a deposit under **Billing**. * Subscribe to a paid plan. Any one of them moves the account to Verified. Identity or business verification is what raises the SMS ceiling to 10,000 a day — see the note under the table. ## Daily ceilings Channels that share a budget share one counter. Counts reset at 00:00 UTC. | Budget | Channels | Unverified | Verified | | - | - | - | - | | SMS | `sms`, `sms_oneway` | 25/day | 200/day, or 10,000/day once identity or business verification is approved | | Voice | `voice` | 5/day | 50/day on Free, no ceiling on paid plans | | Conversational | `whatsapp`, `telegram`, `instagram`, `messenger` | 100/day | 250/day on Free, no ceiling on paid plans or on a Free team that has added funds | | Email | `email` | Plan quota | Plan quota | * Above the first level, the SMS ceiling keys on identity, not on the level: an account that reached Verified by saving a card sends 200 a day until its identity or business verification is approved. A higher limit can be agreed for an account. * Email has no verification gate on single sends. A sender with a verified domain sends from day one, within the plan's quota: 100 a day and 3,000 a month on Free. The monthly cap lifts once the team has added funds: past it, email keeps billing from the balance in 1,000-message blocks. * Every plan, paid ones included, also has a rolling email ceiling by account age: 200 an hour and 1,000 per 24 hours for the first 7 days, 1,000 an hour and 10,000 per 24 hours until day 30, then 5,000 an hour and 50,000 per 24 hours. It counts the last hour and the last 24 hours rather than resetting at 00:00 UTC. Past it, the send is accepted and the message then fails with `errorCode` `EMAIL_RATE_LIMIT_HOURLY` or `EMAIL_RATE_LIMIT_DAILY`. * A daily ceiling never reduces a monthly allowance: 100 a day on the conversational group still reaches the 2,000 messages a month Free includes. * Broadcasts count each recipient against the same ceiling. Recipients beyond it are marked `failed` with `errorCode` `DAILY_LIMIT_EXCEEDED` and are not retried the next day. ## Monthly allowance on the app channels `whatsapp`, `telegram`, `instagram` and `messenger` share one monthly allowance: 2,000 messages on Free. Paid plans have no message cap. A Free team that has added funds keeps going past it, paying from the balance ([pay as you go](#pay-as-you-go)). The counter resets on the 1st at 00:00 UTC. **It counts messages in both directions.** A message a contact sends you consumes one unit exactly as a message you send them does. This is the part that surprises people: a project that has sent 300 messages and received 1,700 has used the whole allowance, and the figure on the dashboard will not match a count of your own sends. The plan card breaks the total into received and sent for that reason. What does not count: * Messages you send from the **WhatsApp Business App on your own phone** while coexistence is on. Meta mirrors them to Zavu so they appear in your inbox, but Zavu did not send them and does not count them. * **Failed sends.** An outbound message counts when it is accepted by the channel, not when it is created. * **SMS, voice and email.** Each is billed or metered on its own terms; see the daily ceilings above and your plan's email quota. ### Pay as you go A Free team that has added funds to its balance at least once is not stopped at the allowance. Past 2,000, each started block of 1,000 messages is prepaid from the balance at $4, which is $0.004 a message, sent or received. The first block is charged on the first message past the allowance, and we email the team when it happens. The \$2 monthly credit Free receives is not a deposit and does not make a team pay as you go. When the balance cannot cover the next block, the team is at the cap below until it adds funds. Pro has no per-message charge on these channels. ### What happens at the cap On Free without funds for the next block, both directions stop: * **Sending** is refused. `POST /v1/messages` answers `429 a2p_limit_exceeded`, and so does every other way of sending: the dashboard composer, an inbox reply, a group reply, a reaction, an AI agent's reply, and each remaining recipient of a broadcast (marked `failed` with `errorCode` `A2P_LIMIT_EXCEEDED`). * **Receiving** is held. An incoming message on those channels is kept, but it does not appear in your inbox and fires no `message.inbound` webhook until the team pays: by adding funds or moving to a paid plan, or, for a pay-as-you-go team stopped by its spend cap, by raising the cap. Held messages are then delivered in the order they arrived, counted and charged like any other message, with `providerTimestamp` set to when the contact sent them and `heldAt` set to when they were held. An AI agent does not reply to them. We email the team the first time this happens in a month, and the inbox shows how many are waiting. An integration that only replies to `message.inbound` never sends while messages are held, so it never sees the `429`: it polls `GET /v1/balance`, which reports the same `heldInbound` and why app messages are paused. ```json theme={null} { "balance": 4800, "currency": "usd", "payAsYouGo": true, "spendCap": 5000, "spentThisMonth": 5000, "appMessages": { "used": 3000, "included": 2000, "paused": "spend_cap" }, "heldInbound": { "count": 7, "max": 1000, "nextExpiresAt": "2026-11-28T14:02:11.000Z" } } ``` `appMessages.paused` is `allowance` (Free without funds at its allowance), `balance` or `spend_cap` (pay as you go), or `null` while messages flow. `appMessages.included` is `null` on a paid plan. * Held messages are kept for 30 days, up to 1,000 per team. Past either limit they are refused and not delivered later. Waiting for the month to reset does not release them. New messages flow again on their own when the counter resets, immediately on a paid plan, or, for a team that has added funds before, as soon as the balance covers the next block. ## Monthly spend cap A team can cap what usage takes from its balance in a calendar month, from **Billing** in the dashboard. A Free team starts with a \$50 cap when its first deposit makes it pay as you go; it can raise, lower or remove it there. Usage is everything charged per use: SMS, voice, email, AI agent replies, Functions, Memory and messages past the plan's allowance. Phone number renewals, 10DLC fees, WhatsApp accounts, connections and seats are not counted, so a cap never stops a renewal. We email the team at 50%, 80% and 100% of the cap. Once it is reached, SMS, voice, email and AI replies answer `402 spend_cap_reached` until the 1st (UTC) or until the cap is raised, and a message that fails on it carries `errorCode` `SPEND_CAP_REACHED`. App messages (WhatsApp, Telegram, Instagram, Messenger) past the allowance answer differently: `429 a2p_limit_exceeded`, with `details.payAsYouGo.reason` set to `spend_cap_reached`. The same Billing screen sets the balance below which we email you (default \$5). ## Risk review Every outbound `sms`, `sms_oneway`, `email` and `voice` message is read before it is sent: the content, and how the account has been sending lately. A broadcast is read once, on its own text, rather than once per recipient. Three things can follow. **It is sent.** Nothing appears in the API, and this is what happens to ordinary traffic. **It is held.** The message waits for a person. It stays `queued` while it waits — there is no new status for a hold, and `MessageStatus` is unchanged — so code that polls `GET /v1/messages/{messageId}` or listens for the webhooks needs no change. Approved, it sends normally and delivery continues as usual. The window is a couple of hours, not days: a one-time code that arrives late is worse than one that never arrives. **It is refused.** The message moves to `failed` and fires `message.failed`. `errorCode` says which happened: | `errorCode` | What it means | | - | - | | `RISK_BLOCKED` | Refused outright, without a hold | | `RISK_REJECTED` | Held for review, and the reviewer did not approve it | | `RISK_REVIEW_EXPIRED` | Held for review, and the window closed before anyone approved it | | `SENDING_SUSPENDED` | The account's sending was suspended while this message was queued | An SMS that fails any of these ways is not charged: the prepaid amount is returned to your balance. Two rules worth knowing before they surprise you: * **A call is never held.** A call cannot arrive two hours late, so a call the review stops is failed rather than placed. It ends with status `failed`. * **A message that cannot be read is held rather than sent.** When the content check is briefly unavailable, the safe answer is to wait for a person. An account with an approved business verification is unaffected, and so is one that has verified something, already sends real traffic, and has a clean recent record. If a message was refused and you believe it should not have been, contact support with the message ID. ## Errors ### `a2p_limit_exceeded` (429) Returned by `POST /v1/messages` and `POST /v1/messages/{messageId}/reactions` when the month's allowance on the app channels is spent and, for a team paying as you go, the balance cannot cover the next block of 1,000. Free plans only: a paid plan is never refused for volume. A pay-as-you-go refusal says so in its message, and its `details.payAsYouGo` carries `blockSize`, `blockPriceCents`, `dashboardUrl` and `reason`: `insufficient_balance` or `spend_cap_reached`. While incoming messages are held, `details.heldInbound` carries `count`, `max` and `nextExpiresAt` (ISO 8601, when the oldest expires). ```json theme={null} { "error": { "code": "a2p_limit_exceeded", "message": "Your sending is paused — monthly limit reached (2,000/2,000). The allowance counts messages you receive as well as the ones you send. Upgrade to Pro for unlimited messages/mo to restore it: https://dashboard.zavu.dev/upgrade?from=a2p-limit", "details": { "limit": 2000, "used": 2000, "remaining": 0, "tier": "free", "upgrade": { "nextTier": "hobby", "nextTierName": "Pro", "nextTierPrice": "$20/mo", "upgradeUrl": "https://dashboard.zavu.dev/upgrade?from=a2p-limit" } } } } ``` A pay-as-you-go team stopped by its spend cap, with messages held: ```json theme={null} { "error": { "code": "a2p_limit_exceeded", "message": "Your sending is paused: the 2,000 included messages are used and your monthly spend cap leaves no room for the next 1,000 ($4.00). Raise the cap in Billing to resume: https://dashboard.zavu.dev/billing?from=a2p-limit. The allowance counts messages you receive as well as the ones you send. ...", "details": { "limit": 2000, "used": 3000, "remaining": 0, "tier": "free", "heldInbound": { "count": 7, "max": 1000, "nextExpiresAt": "2026-11-28T14:02:11.000Z" }, "payAsYouGo": { "blockSize": 1000, "blockPriceCents": 400, "dashboardUrl": "https://dashboard.zavu.dev/billing?from=a2p-limit", "reason": "spend_cap_reached" } } } } ``` `upgrade.nextTier` is the plan's id (`hobby`); `nextTierName` is what it is sold as (Pro). Distinct from `daily_limit_exceeded`, which clears at 00:00 UTC. Sending clears on the 1st of the month, when the team adds funds (pay as you go), raises its spend cap, or moves to a paid plan. Held incoming messages are different: only paying releases them (funds, a raised cap, or a paid plan), never the 1st. ### `daily_limit_exceeded` (429) Returned by `POST /v1/messages` and `POST /v1/calls` when the channel group's ceiling for today is spent. ```json theme={null} { "code": "daily_limit_exceeded", "message": "Daily SMS limit reached (25/day). Verify your identity or add a payment method to raise it.", "details": { "limit": 25, "remaining": 0, "dashboardUrl": "/kyc" } } ``` `details.limit` is the ceiling that applied. Wait for 00:00 UTC, or raise it: identity verification or a payment method for the SMS, voice and conversational groups, a plan upgrade for email. ### `sending_suspended` (403) Returned by `POST /v1/messages` and `POST /v1/calls` when an account's sending has been suspended after the review refused several of its messages. Every channel is refused while it lasts, and a message already queued fails with `errorCode` `SENDING_SUSPENDED`. ```json theme={null} { "code": "sending_suspended", "message": "Sending from this account is suspended after Zavu's automated risk review refused several messages. Contact support to have it reviewed.", "details": { "dashboardUrl": "/support" } } ``` A suspension is lifted by a person. Contact support; waiting does not clear it. ### `ten_dlc_required` (403) Returned by `POST /v1/messages` on `sms` and `sms_oneway` when the destination is a US or Canadian (+1) number and the project has no approved 10DLC campaign. Register a brand and a campaign in the dashboard; both need an approved business verification. See [10DLC](/guides/10dlc/overview). ## Test-mode keys A `zv_test_` key is separate from the levels above, and narrower than they are. Whatever the account has proven: * **WhatsApp by default.** Without a configured sender the key sends from Zavu's shared sandbox number, and any other channel is refused with `400 Sandbox mode only supports WhatsApp`. * **Only to a phone on your team.** On WhatsApp, Telegram, Instagram and Messenger the recipient must be a phone number verified in your account. Anything else is refused with `403`. * **No calls.** `POST /v1/calls` refuses a test key with `400`. A number is verified from the phone itself, which is what makes the check mean something: 1. In the dashboard, go to **Sandbox** and click **Generate Verification Code**. 2. Scan the QR code or click **Open WhatsApp** on the phone you want to verify. 3. Send the pre-filled `VERIFY-` message to Zavu's sandbox number. The number is marked verified automatically. Up to 5 numbers per project; a code expires after 10 minutes. There is nothing to type into the dashboard, no API call, and no support ticket. The messages are delivered for real — a test key changes who you can reach, not whether the message arrives. ## Next Steps > [card] [Quickstart](/quickstart-messaging) > > Send your first message > [card] [Rate Limiting](/concepts/rate-limiting) > > Per-minute API request limits # Sub-Accounts Source: https://docs.zavu.dev/concepts/sub-accounts Isolate messaging for your customers with sub-accounts: independent API keys, spend limits, routing and reporting per tenant. ## Sub-Accounts Sub-accounts let you create **isolated messaging environments** for your customers, clients, or business units — each with their own API key, spending limits, and usage tracking. All charges are billed to your team's balance. ## How It Works Every team has one **main account** (your primary project) and can create multiple **sub-accounts**. Sub-accounts share your team's **balance**, but are fully isolated — each has its own API keys, senders, phone numbers, contacts, and usage tracking. ``` Team (Balance: $500) ├── Main Account (your project) ├── Sub-Account: Client ABC (totalSpent: $45, creditLimit: $100) ├── Sub-Account: Client XYZ (totalSpent: $120, creditLimit: $200) └── Sub-Account: Partner Co (totalSpent: $30, no limit) ``` > [!WARNING] > > Each team can have **only one main account**. All additional projects must be sub-accounts. If you need another main account, create a new team. ## Key Concepts | Concept | Description | | - | - | | **Main Account** | Your primary project. One per team. Can create and manage sub-accounts. | | **Sub-Account** | A fully isolated project with its own API keys, senders, phone numbers, and contacts. Created via the main account's API. | | **Credit Limit** | Optional spending cap in cents. Messages are blocked when reached. | | **Total Spent** | Running total of all charges for the sub-account, in cents. | | **Shared Balance** | All sub-accounts draw from the team's balance. There are no separate sub-account balances. | ## Use Cases > [card] SaaS Platforms > > Give each of your customers their own API key with independent spending limits. Track usage per customer for billing. > [card] Agencies > > Manage multiple client accounts under one team. Set credit limits to control spending per client. > [card] Business Units > > Separate messaging for different departments or product lines while sharing the same balance. > [card] Resellers > > Provision accounts for downstream customers with their own API keys and usage tracking. ## Billing Model Sub-accounts don't have their own balance. All charges are deducted from the **parent team's balance**: ``` Customer sends message via sub-account API key ↓ Zavu charges the message cost ↓ Team balance decreases Sub-account totalSpent increases ↓ If totalSpent >= creditLimit → messages blocked ``` > [!NOTE] > > The `GET /v1/balance` endpoint returns different data depending on the API key used. A sub-account API key includes `isSubAccount: true`, `totalSpent`, and `creditLimit` in the response. ## Sub-Account vs Main Account API Keys Both types of API keys can send messages, manage contacts, and use all messaging features. The differences: | Feature | Main Account | Sub-Account | | - | - | - | | Send messages | Yes | Yes | | Manage contacts | Yes | Yes | | Create sub-accounts | Yes | No | | Manage sub-accounts | Yes | No | | Credit limit enforcement | No | Yes | | Usage tracking (`totalSpent`) | No | Yes | | Balance endpoint shows `isSubAccount` | `false` | `true` | ## Next Steps > [card] [Create Sub-Accounts](/guides/sub-accounts/creating) > > Provision sub-accounts via API with optional credit limits > [card] [Manage Sub-Accounts](/guides/sub-accounts/managing) > > Update credit limits, manage API keys, and monitor spending > [card] [Deletion & Deactivation](/guides/sub-accounts/deletion) > > Deactivate sub-accounts and understand the deletion grace period > [card] [API Reference](/api-reference/sub-accounts/create-sub-account) > > Full API reference for sub-account endpoints # Templates Source: https://docs.zavu.dev/concepts/templates Understanding message templates for multi-channel messaging. Reusable structures for WhatsApp, SMS, Telegram and Email with variables, categories and ## Templates Templates are reusable message formats for sending structured messages across WhatsApp, SMS, Telegram, Instagram, and Email. They are especially important for WhatsApp, which requires template approval for business-initiated conversations. ## Why Templates? Templates solve three key problems: 1. **WhatsApp Compliance**: Meta requires businesses to use approved templates when initiating conversations outside the 24-hour window 2. **Consistency**: Ensure your transactional messages follow a consistent format 3. **Multi-Channel Delivery**: Define one template with channel-specific bodies, then send it on any channel ## Template Structure A template consists of: ```json theme={null} { "id": "tmpl_abc123", "name": "order_confirmation", "language": "en", "body": "Hi {{1}}, your order {{2}} has been confirmed!", "category": "UTILITY", "variables": ["customer_name", "order_id"], "status": "approved" } ``` ### Fields | Field | Description | | - | - | | `name` | Unique identifier (lowercase, underscores) | | `language` | Language code (e.g., "en", "es", "pt") | | `body` | Message text with variable placeholders | | `category` | WhatsApp category (required for WhatsApp submission, optional for other channels) | | `variables` | List of variable names for documentation | | `status` | Approval status | ## Template Variables Templates support dynamic content through variables. Two formats are supported: ### Numbered Variables (WhatsApp Native) Use `{{1}}`, `{{2}}`, `{{3}}` for WhatsApp compatibility: ``` "Hi {{1}}, your order {{2}} ships on {{3}}" ``` ### Named Variables Use `{{name}}` for better readability: ``` "Hi {{customer_name}}, your order {{order_id}} ships on {{date}}" ``` When you **create a template in Zavu**, named variables are automatically converted to numbered format (`{{1}}`, `{{2}}`) at the moment it is submitted to Meta. The template is therefore approved as **positional** at Meta, even though Zavu keeps the readable named body for display. Templates **imported from an existing WhatsApp Business Account** keep whatever format Meta has on file — these can be **named** (`{{customer_name}}`) or **positional** (`{{1}}`). Zavu detects the format per template and sends the correct payload either way. > [!NOTE] > > Whichever format a template uses, you always pass values the same way through > `templateVariables` — keyed by name (`{ "customer_name": "John" }`) or by > position (`{ "1": "John" }`). Zavu maps them to the format Meta expects. See > [Sending templates](/guides/whatsapp/templates/sending#how-variables-work) for > details. ### Contact Variables Use `{{contact.*}}` variables to auto-resolve values from the recipient's contact metadata: | Variable | Resolved From | | - | - | | `{{contact.first_name}}` | Contact metadata `first_name` | | `{{contact.last_name}}` | Contact metadata `last_name` | | `{{contact.phone}}` | Contact's phone number | | `{{contact.email}}` | Contact's primary email | | `{{contact.country}}` | Contact's country code | | `{{contact.custom_field}}` | Any custom metadata field | ``` "Hola {{contact.first_name}}, tu pedido esta listo." ``` When the message is sent, Zavu looks up the recipient's contact record and replaces these variables automatically. If the contact doesn't exist or the field is empty, the variable is replaced with an empty string. > [!TIP] > > Contact variables work on all channels: SMS, Telegram, Instagram, and Email. You can also pass explicit values via `templateVariables` as a fallback. ## WhatsApp Categories WhatsApp requires every template to be categorized: | Category | Use Case | Examples | | - | - | - | | **UTILITY** | Transactional messages | Order confirmations, shipping updates, appointment reminders | | **MARKETING** | Promotional content | Sales, offers, newsletters | | **AUTHENTICATION** | Verification codes | OTPs, login codes, 2FA | > [!WARNING] > > Marketing templates have stricter approval requirements and may have different pricing. Use UTILITY for transactional messages. ## Authentication Templates Authentication templates are used for sending verification codes (OTPs) to users. These templates have special requirements and behavior. ### Requirements > [!WARNING] > > Meta requires your WhatsApp Business Account to meet these criteria before you can create AUTHENTICATION templates: > > 1. **Business Verification**: Your business must be verified by Meta > 2. **Conversation History**: At least **2,000 business-initiated conversations** in the last 30 days > > If your account doesn't meet these requirements, AUTHENTICATION templates will fail with a permission error. ### How Authentication Templates Work Unlike regular templates, authentication templates have a **pre-defined message format** controlled by Meta. When you create an authentication template: * The message body is **automatically generated** by Meta * The format is: `{{1}} is your verification code.` * You cannot customize the body text ### OTP Button Types Authentication templates support two types of OTP buttons: | Button Type | Description | Use Case | | - | - | - | | **COPY\_CODE** | Shows a "Copy Code" button | User manually copies and pastes the code | | **ONE\_TAP** | Enables Android autofill | Automatic code entry on Android devices | ### ONE\_TAP Button Requirements For ONE\_TAP buttons (Android autofill), you must provide: ```json theme={null} { "buttons": [{ "type": "otp", "text": "Autofill", "otpType": "ONE_TAP", "packageName": "com.yourapp.package", "signatureHash": "your_app_signature_hash" }] } ``` | Field | Description | | - | - | | `packageName` | Your Android app's package name | | `signatureHash` | Your Android app's signature hash for SMS verification | ### Optional Security Features Authentication templates can include: | Feature | Description | | - | - | | `addSecurityRecommendation` | Adds "Do not share this code with anyone" disclaimer | | `codeExpirationMinutes` | Shows expiration time (1-90 minutes) in footer | ### Creating an Authentication Template ```bash theme={null} curl -X POST https://api.zavu.dev/v1/templates \ -H "Authorization: Bearer zv_live_xxx" \ -H "Content-Type: application/json" \ -d '{ "name": "verification_code", "language": "en", "body": "", "whatsappCategory": "AUTHENTICATION", "buttons": [{ "type": "otp", "text": "Copy Code", "otpType": "COPY_CODE" }], "addSecurityRecommendation": true, "codeExpirationMinutes": 10 }' ``` > [!NOTE] > > The `body` field should be empty or omitted for authentication templates. Meta will automatically generate the message body. ## WhatsApp Approval Workflow WhatsApp templates must be approved by Meta before use: ``` Create Template (draft) | v Submit to WhatsApp (pending) | v Meta Reviews (24-48 hours) | +---+---+ | | Approved Rejected | | v v Ready Fix & Resubmit ``` ### Template Statuses | Status | Description | | - | - | | `draft` | Created but not submitted | | `pending` | Submitted, awaiting Meta review | | `approved` | Ready to use | | `rejected` | Rejected by Meta (see reason) | ## Creating Templates ### Via API ```bash theme={null} curl -X POST https://api.zavu.dev/v1/templates \ -H "Authorization: Bearer zv_live_xxx" \ -H "Content-Type: application/json" \ -d '{ "name": "order_shipped", "language": "en", "body": "Hi {{1}}, your order {{2}} has shipped and will arrive by {{3}}.", "whatsappCategory": "UTILITY", "variables": ["customer_name", "order_id", "delivery_date"] }' ``` ### Response ```json theme={null} { "id": "tmpl_xyz789", "name": "order_shipped", "language": "en", "body": "Hi {{1}}, your order {{2}} has shipped...", "category": "UTILITY", "status": "draft", "variables": ["customer_name", "order_id", "delivery_date"], "createdAt": "2025-01-15T10:30:00Z" } ``` ## Rich Template Components WhatsApp templates can include rich components: ### Header Add a header with text, image, video, or document: ```json theme={null} { "headerType": "image", "headerContent": "https://example.com/logo.png" } ``` ### Footer Add a footer text: ```json theme={null} { "footer": "Reply STOP to unsubscribe" } ``` ### Buttons Add interactive buttons: ```json theme={null} { "buttons": [ { "type": "quick_reply", "text": "Track Order" }, { "type": "url", "text": "View Details", "url": "https://example.com/orders/{{1}}" } ] } ``` ## Sending Template Messages To send a template message, specify the template ID and variables: ```typescript TypeScript theme={null} await client.messages.send({ to: "+14155551234", messageType: "template", content: { templateId: "tmpl_abc123", templateVariables: { "1": "John", "2": "ORD-12345", "3": "January 20th" } } }); ``` ```python Python theme={null} client.messages.send( to="+14155551234", message_type="template", content={ "templateId": "tmpl_abc123", "templateVariables": { "1": "John", "2": "ORD-12345", "3": "January 20th" } } ) ``` ```ruby Ruby theme={null} client.messages.send_( to: "+14155551234", message_type: "template", content: { templateId: "tmpl_abc123", templateVariables: { "1": "John", "2": "ORD-12345", "3": "January 20th" } } ) ``` ```go Go theme={null} result, err := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("+14155551234"), MessageType: zavudev.String("template"), Content: &zavudev.MessageContentParams{ TemplateID: zavudev.String("tmpl_abc123"), TemplateVariables: map[string]string{ "1": "John", "2": "ORD-12345", "3": "January 20th", }, }, }) ``` ```php PHP theme={null} $result = $client->messages->send([ 'to' => '+14155551234', 'messageType' => 'template', 'content' => [ 'templateId' => 'tmpl_abc123', 'templateVariables' => [ '1' => 'John', '2' => 'ORD-12345', '3' => 'January 20th', ], ], ]); ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer zv_live_xxx" \ -H "Content-Type: application/json" \ -d '{ "to": "+14155551234", "messageType": "template", "content": { "templateId": "tmpl_abc123", "templateVariables": { "1": "John", "2": "ORD-12345", "3": "January 20th" } } }' ``` ### Templates with Dynamic URL Buttons If your template has a URL button with a `{{1}}` placeholder (e.g., `https://example.com/orders/{{1}}`), pass the value through `templateButtonVariables`. Keys are the **button index** (0-based) in the template's `buttons` array. ```bash theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer zv_live_xxx" \ -H "Content-Type: application/json" \ -d '{ "to": "+14155551234", "messageType": "template", "content": { "templateId": "tmpl_abc123", "templateVariables": { "1": "John" }, "templateButtonVariables": { "0": "ORD-12345" } } }' ``` | Field | Keys | What it substitutes | | - | - | - | | `templateVariables` | Position in body (`"1"`, `"2"`, ...) | Body placeholders | | `templateButtonVariables` | Button index (`"0"`, `"1"`, `"2"`) | The `{{1}}` placeholder inside that button's URL | > [!WARNING] > > WhatsApp URL buttons only accept `{{1}}` (positional, single digit, no whitespace, no name). Named or padded placeholders like `{{token}}` or `{{ 1 }}` are stored as literal text and cannot be substituted. See the [WhatsApp templates guide](/guides/whatsapp/templates/sending#sending-templates-with-dynamic-url-buttons) for the full ruleset. ## Multi-Channel Templates Templates support channel-specific bodies. When a message is sent, Zavu uses the channel-specific body if available, falling back to the default `body`. ```json theme={null} { "name": "order_confirmation", "body": "Hi {{1}}, your order {{2}} is confirmed!", "smsBody": "Order {{2}} confirmed for {{1}}. Track at example.com", "telegramBody": "Hi {{1}}, your order {{2}} is confirmed! Check your Telegram for updates.", "instagramBody": "Hi {{1}}! Order {{2}} confirmed.", "emailSubject": "Order {{2}} Confirmed", "emailHtmlBody": "

Order Confirmed

Hi {{1}}...

" } ``` | Field | Channel | Fallback | | - | - | - | | `body` | Default for all channels | - | | `smsBody` | SMS, SMS One-Way | `body` | | `telegramBody` | Telegram | `body` | | `instagramBody` | Instagram | `body` | | `emailSubject` | Email subject | - | | `emailHtmlBody` | Email HTML body | `body` (plain text) | ### Sending Templates on Different Channels ```bash Telegram theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "123456789", "channel": "telegram", "messageType": "template", "content": { "templateId": "tmpl_abc123", "templateVariables": { "1": "John", "2": "ORD-12345" } } }' ``` ```bash SMS theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "+14155551234", "channel": "sms", "messageType": "template", "content": { "templateId": "tmpl_abc123", "templateVariables": { "1": "John", "2": "ORD-12345" } } }' ``` > [!NOTE] > > WhatsApp templates require Meta approval before use. Templates on SMS, Telegram, and Instagram do not require external approval. ## Managing Templates ### List Templates ```bash theme={null} curl https://api.zavu.dev/v1/templates \ -H "Authorization: Bearer zv_live_xxx" ``` ### Get Template ```bash theme={null} curl https://api.zavu.dev/v1/templates/tmpl_abc123 \ -H "Authorization: Bearer zv_live_xxx" ``` ### Delete Template ```bash theme={null} curl -X DELETE https://api.zavu.dev/v1/templates/tmpl_abc123 \ -H "Authorization: Bearer zv_live_xxx" ``` ## Best Practices > [card] Keep It Short > > WhatsApp has character limits. Keep your templates concise and actionable. > [card] Use UTILITY Category > > For transactional messages, always use UTILITY to improve approval chances. > [card] Test Before Launch > > Create templates early in development. Approval can take 24-48 hours. > [card] Handle Rejections > > If rejected, review Meta's guidelines, fix the issue, and resubmit. ## Common Rejection Reasons | Reason | Solution | | - | - | | Promotional content in UTILITY | Change category to MARKETING or remove promotional language | | Missing variable examples | Provide clear example values for each variable | | Inappropriate content | Review WhatsApp commerce policy | | Poor grammar/spelling | Proofread and fix language errors | ## Next Steps > [card] [WhatsApp Templates](/guides/whatsapp/templates/sending) > > Deep dive into WhatsApp template features > [card] [Senders](/concepts/senders) > > Learn how templates work with senders ## API Reference Manage templates programmatically: * [List templates](/api-reference/list-templates) — fetch all templates for your project * [Get a template](/api-reference/get-template) — retrieve template details by ID * [Delete a template](/api-reference/delete-template) — remove an unapproved template # Brand Registration Source: https://docs.zavu.dev/guides/10dlc/brand-registration Register your business brand for 10DLC SMS compliance ## Brand Registration A **Brand** represents your business entity in the 10DLC ecosystem. It's the first step to sending compliant SMS messages in the United States. Register **one brand per legal entity**. A brand holds its own campaigns, and each campaign holds its own phone numbers — so an account operating several companies registers several brands, and each one pays its own registration fee. ## Prerequisites Before registering your brand, complete these steps: 1. **Business Verification (KYB) approved** - Carriers register the brand against a vetted legal entity. Complete it at [Compliance → Verification](https://dashboard.zavu.dev/kyb); submitting a brand before it is approved returns `403 kyb_required` 2. **Business Information Ready** - Have your company details available, matching the entity you are registering ## Required Information You'll need to provide: | Field | Description | Example | | - | - | - | | Legal Company Name | Official registered name | Acme Corporation | | DBA (if applicable) | Doing Business As name | Acme | | Company Type | Entity type | Corporation, LLC, etc. | | EIN / Tax ID | Federal tax identification | 12-3456789 | | Country | Country of registration | United States | | Street Address | Business address | 123 Main St | | City | City | San Francisco | | State | State/Province | California | | Postal Code | ZIP code | 94102 | | Website | Company website | [https://acme.com](https://acme.com) | | Vertical | Industry category | Technology, Retail, etc. | > [!TIP] > > Ensure your company information matches official records. Discrepancies can delay approval. ## How to Register ### Via Dashboard 1. Navigate to **Compliance** → **10DLC** in your [Dashboard](https://dashboard.zavu.dev/10dlc) 2. Click **Register brand** 3. Review the details — they are prefilled from your approved business verification — and correct anything that differs for this entity 4. Submit. The brand appears in the list with its carrier status, and campaigns unlock once it is registered ### Via API Create a brand in draft status, then submit it for review: ```bash theme={null} # Step 1: Create brand (draft) curl -X POST https://api.zavu.dev/v1/10dlc/brands \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "entityType": "PRIVATE_PROFIT", "displayName": "Acme Corp", "companyName": "Acme Corporation", "ein": "12-3456789", "email": "compliance@acme.com", "phone": "+14155551234", "street": "123 Main St", "city": "San Francisco", "state": "CA", "postalCode": "94102", "country": "US", "website": "https://acme.com", "vertical": "Technology" }' # Step 2: Submit for review curl -X POST https://api.zavu.dev/v1/10dlc/brands/{brandId}/submit \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" # Step 3: Check status curl https://api.zavu.dev/v1/10dlc/brands/{brandId}/sync \ -X POST \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` ### Cost * **\$4.00 USD per brand** (one-time, charged from your account balance when you submit) > [!NOTE] > > This is TCR's brand registration fee, passed through at cost. It is charged per brand — a second legal entity is a second brand and a second $4.00 — and refunded if the carrier rejects the registration. Campaign registration is billed separately: $15.00 one-time plus $10.00/month, or $2.00 + \$2.00 for `LOW_VOLUME`. ## Approval Process After submission, your brand goes through vetting: ``` Submitted → Under Review → Approved/Rejected 1-3 days ``` ### Status Meanings | Status | Description | | - | - | | **Draft** | Created, not yet submitted to the carrier | | **Pending** | Brand submitted, awaiting review | | **Verified** | Registered AND the carrier verified the business behind it. Ready for campaigns | | **Unverified** | Registered, but the carrier did not verify the business — the identity check did not pass, or has not been resolved. Campaigns are allowed, with lower daily limits | | **Rejected** | Brand rejected (see reason provided) | | **Failed** | Registration failed before reaching the carrier; the fee is refunded | `identityStatus` on the brand carries the carrier's own wording for the identity verdict (`VERIFIED`, `VETTED_VERIFIED`, `SELF_DECLARED`, `UNVERIFIED`), and is `null` while the identity has not been resolved. A registered brand whose identity is anything other than verified reports `status: unverified` — a registration that exists is not the same thing as a business that was checked. ### What Happens During Review? The Campaign Registry (TCR) verifies: * Business legitimacy and registration * Website content and ownership * Industry classification * Compliance history ## Trust Scores Upon approval, your brand receives a **Trust Score** that determines your messaging limits: | Score | Description | Daily Limit | | - | - | - | | Low | New or unverified businesses | 2,000 messages | | Medium | Standard verified businesses | 10,000 messages | | High | Established, vetted enterprises | 200,000+ messages | > [!TIP] > > Trust scores can improve over time with good messaging practices and additional vetting. ## If Rejected If your brand is rejected: 1. Review the rejection reason provided 2. Correct any issues with your business information 3. Contact support if you believe the rejection was in error 4. Resubmit with accurate information Common rejection reasons: * Mismatched business information * Website not accessible or incomplete * Industry not supported for A2P messaging ## Next Steps Once your brand is approved: > [card] [Create a Campaign](/guides/10dlc/campaigns) > > Register your messaging use case > [card] [10DLC Overview](/guides/10dlc/overview) > > Review the full process # Campaign Registration Source: https://docs.zavu.dev/guides/10dlc/campaigns Register your messaging campaign for 10DLC SMS compliance ## Campaign Registration A **Campaign** describes your messaging use case. After your brand is approved, you must register at least one campaign to start sending SMS messages in the United States. ## Prerequisites Before registering a campaign: 1. **Approved Brand** - Your brand must be verified (see [Brand Registration](/guides/10dlc/brand-registration)) 2. **US Phone Number** - At least one phone number to assign to your campaign ## Use Case Types Select the use case that best describes your messaging: | Use Case | Description | Registration | Monthly Fee | | - | - | - | - | | 2FA / Authentication | One-time passwords, login codes | \$15 | \$10/month | | Account Notifications | Account updates, alerts | \$15 | \$10/month | | Customer Care | Support conversations | \$15 | \$10/month | | Delivery Notifications | Shipping updates, ETAs | \$15 | \$10/month | | Fraud Alerts | Security warnings | \$15 | \$10/month | | Marketing | Promotional messages, offers | \$15 | \$10/month | | Low Volume Mixed | Multiple use cases, low volume | \$2 | \$2/month | | Polling and Voting | Surveys, feedback collection | \$15 | \$10/month | | Public Service Announcement | Non-profit, public info | \$15 | \$10/month | | Security Alerts | Security notifications | \$15 | \$10/month | > [!TIP] > > Choose the most specific use case for your messaging. `LOW_VOLUME` is the only cheaper tier — it carries lower throughput, so pick it only if your volume really is low. ## Registration Steps ### Step 1: Campaign Basics Provide general information about your campaign: * **Campaign Name** - A descriptive name for internal reference * **Use Case** - Select from the list above * **Description** - Brief explanation of what messages you'll send * **Subscriber Opt-in** - How users consent to receive messages ### Step 2: Sample Messages Provide 2-5 sample messages that represent your typical content: ``` Example for 2FA: "Your verification code is 123456. This code expires in 10 minutes." Example for Delivery: "Hi {{name}}, your order #{{order_id}} has shipped! Track it here: {{tracking_url}}" ``` > [!WARNING] > > Sample messages must accurately represent your actual messaging. Misleading samples can result in campaign rejection. ### Step 3: Compliance Settings Configure compliance options: | Setting | Description | | - | - | | **Opt-in Keywords** | Words that subscribe users (e.g., START, YES) | | **Opt-out Keywords** | Words that unsubscribe users (e.g., STOP, CANCEL) | | **Help Keywords** | Words that trigger help message (e.g., HELP, INFO) | | **Age-gated Content** | Whether content is restricted to adults | | **Direct Lending** | Whether messages relate to lending services | > [!NOTE] > > Standard opt-out (STOP) handling is automatic. Users who reply STOP will be automatically unsubscribed. ### Step 4: Review and Submit Review all information before submitting: 1. Verify campaign details are accurate 2. Confirm sample messages are representative 3. Check compliance settings 4. Submit for review ### Via API ```bash theme={null} # Step 1: Create campaign (draft) curl -X POST https://api.zavu.dev/v1/10dlc/campaigns \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "brandId": "brand_abc123", "name": "Order Notifications", "useCase": "ACCOUNT_NOTIFICATION", "description": "Send order status updates to customers who opted in at checkout.", "sampleMessages": [ "Hi {{name}}, your order #{{order_id}} has shipped!", "Your order #{{order_id}} has been delivered." ], "subscriberOptIn": true, "subscriberOptOut": true, "subscriberHelp": true, "numberPooling": false, "directLending": false, "embeddedLink": true, "embeddedPhone": false, "affiliateMarketing": false, "ageGated": false }' # Step 2: Submit for carrier review curl -X POST https://api.zavu.dev/v1/10dlc/campaigns/{campaignId}/submit \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` ## Cost | Item | Cost | Type | | - | - | - | | Campaign Registration | $15.00 ($2.00 for `LOW_VOLUME`) | One-time, charged when you submit | | Monthly Fee | $10.00/month ($2.00 for `LOW_VOLUME`) | Recurring | > [!NOTE] > > These are TCR's fees, passed through at cost and charged from your balance. The one-time fee is charged when the campaign is submitted and refunded if the carrier rejects it. The brand's own \$4.00 registration fee is separate. ## Approval Process After submission, your campaign goes through carrier review: ``` Submitted → Under Review → Approved/Rejected 1-7 days ``` ### Review Timeline | Use Case | Typical Review Time | | - | - | | 2FA / Transactional | 1-2 business days | | Customer Care | 2-3 business days | | Marketing | 5-7 business days | ### Status Meanings | Status | Description | | - | - | | **Pending** | Campaign submitted, awaiting review | | **Approved** | Ready to assign phone numbers | | **Rejected** | Campaign rejected (see reason) | ## If Rejected If your campaign is rejected: 1. Review the rejection reason provided 2. Update your campaign details or sample messages 3. Resubmit for review Common rejection reasons: * Sample messages don't match use case * Missing opt-in/opt-out information * Prohibited content type * Incomplete description ## Campaign Limits Each campaign has messaging limits based on your brand's Trust Score: | Trust Score | Daily SMS Limit | Messages/Second | | - | - | - | | Low | 2,000 | 0.2 | | Medium | 10,000 | 1 | | High | 200,000 | 10+ | > [!TIP] > > You can create multiple campaigns for different use cases. Each campaign can have its own phone numbers assigned. ## Next Steps Once your campaign is approved: > [card] [Assign Phone Numbers](/guides/10dlc/phone-assignment) > > Connect your numbers to the campaign > [card] [10DLC Overview](/guides/10dlc/overview) > > Review the full process # 10DLC Overview Source: https://docs.zavu.dev/guides/10dlc/overview Learn about 10DLC registration requirements for sending SMS in the United States ## 10DLC Compliance 10DLC (10-Digit Long Code) is the industry standard for Application-to-Person (A2P) messaging in the United States. All businesses sending SMS to US phone numbers must register their brand and messaging campaigns. > [!WARNING] > > Starting February 2025, unregistered traffic will be blocked by carriers. Complete your 10DLC registration to ensure uninterrupted SMS delivery. ## Why 10DLC? US carriers (AT\&T, T-Mobile, Verizon) require 10DLC registration to: * **Reduce spam** - Verify legitimate business senders * **Improve deliverability** - Registered traffic gets priority routing * **Increase throughput** - Higher message limits for trusted senders * **Ensure compliance** - Meet TCPA and carrier requirements ## Registration Process 10DLC registration consists of two steps: ``` ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ 1. Register │ → │ 2. Register │ → │ 3. Assign │ │ Brand │ │ Campaign │ │ Numbers │ └─────────────────┘ └─────────────────┘ └─────────────────┘ $4 one-time $15 one-time Ready to send! 1-3 days + $10/month 1-7 days ``` > [!NOTE] > > Fees are TCR's, passed through at cost and charged from your balance when you submit: \*\*$4 per brand** (refunded if the carrier rejects it) and **$15 one-time plus $10/month per campaign** — $2 + $2 for the `LOW_VOLUME` use case. A brand is registered per legal entity, and each brand pays its own $4. ### Step 1: Brand Registration Your **Brand** represents your business entity. You provide company information like legal name, EIN/Tax ID, address, and website. This is reviewed by The Campaign Registry (TCR). ### Step 2: Campaign Registration A **Campaign** describes your messaging use case. You specify the type of messages (2FA, marketing, notifications), provide sample messages, and confirm compliance settings. ### Step 3: Phone Number Assignment Once your campaign is approved, assign your US phone numbers to it. Messages sent from these numbers will be delivered as registered A2P traffic. ## Costs | Item | Cost | Type | | - | - | - | | Brand Registration | \$4 | One-time, per brand, charged when you submit it | | Campaign Registration | $15 ($2 for `LOW_VOLUME`) | One-time, charged when you submit the campaign | | Campaign Monthly Fee | $10/month ($2 for `LOW_VOLUME`) | Recurring, while the campaign is approved | > [!TIP] > > Fees are passed through at cost and charged from your balance. A brand or campaign the carriers reject is refunded. ## Throughput Limits Your messaging throughput depends on your **Trust Score**, assigned by TCR based on your brand vetting: | Trust Score | Daily Limit | Messages/Second | | - | - | - | | Low | 2,000 | 0.2 | | Medium | 10,000 | 1 | | High | 200,000 | 10+ | > [!NOTE] > > Most small businesses receive a Medium trust score. Large enterprises with established brands typically receive High scores. ## Prerequisites Before starting 10DLC registration, you need: 1. **Approved Business Verification (KYB)** - Carriers register a brand against a vetted legal entity, so this is a hard prerequisite: submitting a brand or a campaign without it returns `403 kyb_required`. Complete it at [Compliance → Verification](https://dashboard.zavu.dev/kyb) 2. **US phone number** - At least one number to assign to your campaign 3. **Business information** - Legal name, EIN, address, website ## Next Steps > [card] [Register Your Brand](/guides/10dlc/brand-registration) > > Start with brand registration > [card] [Create a Campaign](/guides/10dlc/campaigns) > > Set up your messaging campaign ## API Reference Manage 10DLC brands and campaigns programmatically: * [Get a 10DLC brand](/api-reference/10dlc/get-10dlc-brand) — retrieve brand registration details * [Update a 10DLC brand](/api-reference/10dlc/update-10dlc-brand) — modify a draft brand before submission * [Delete a 10DLC brand](/api-reference/10dlc/delete-10dlc-brand) — remove a brand from your account * [Get a 10DLC campaign](/api-reference/10dlc/get-10dlc-campaign) — retrieve campaign details and status * [Delete a 10DLC campaign](/api-reference/10dlc/delete-10dlc-campaign) — remove a campaign you no longer use # Phone Number Assignment Source: https://docs.zavu.dev/guides/10dlc/phone-assignment Assign phone numbers to your 10DLC campaigns ## Phone Number Assignment After your campaign is approved, the final step is assigning your US phone numbers to it. Messages sent from assigned numbers will be delivered as registered A2P traffic with higher deliverability. ## Prerequisites Before assigning phone numbers: 1. **Approved Campaign** - Your campaign must be approved (see [Campaign Registration](/guides/10dlc/campaigns)) 2. **US Phone Number** - At least one active US phone number in your account ## How to Assign Numbers ### Via Dashboard 1. Navigate to **Phone Numbers** in your [Dashboard](https://dashboard.zavu.dev/phone-numbers) 2. Find the phone number you want to assign 3. Click on the phone number to open details 4. Select the approved campaign from the dropdown 5. Save changes > [!TIP] > > You can assign multiple phone numbers to the same campaign. This is useful for load balancing or geographic distribution. ### Via API ```bash theme={null} # Assign a phone number to an approved campaign curl -X POST https://api.zavu.dev/v1/10dlc/campaigns/{campaignId}/phone-numbers \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "phoneNumberId": "pn_abc123" }' # List phone numbers assigned to a campaign curl https://api.zavu.dev/v1/10dlc/campaigns/{campaignId}/phone-numbers \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" # Remove a phone number from a campaign curl -X DELETE https://api.zavu.dev/v1/10dlc/campaigns/{campaignId}/phone-numbers/{assignmentId} \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` Each assignment has a `status`: `pending` (sent to the carrier, not confirmed yet), `active` (the number is on the campaign and can send) or `failed` (the carrier refused it; see `failureReason`). The list paginates with `limit` and `cursor` like every other list, and a number that was unassigned is no longer listed. ## Assignment Rules | Rule | Description | | - | - | | One campaign per number | Each phone number can only be assigned to one campaign at a time | | Multiple numbers per campaign | A campaign can have multiple phone numbers assigned | | US numbers only | Only US phone numbers require 10DLC registration | | Active status required | Phone number must be active to be assigned | ## After Assignment Once a phone number is assigned to an approved campaign: * **Immediate effect** - Messages are sent as registered A2P traffic * **Higher throughput** - Benefit from your Trust Score limits * **Better deliverability** - Carrier filtering is reduced * **Compliance tracking** - Messages are associated with your campaign > [!NOTE] > > It may take a few minutes for the assignment to propagate to all carriers. ## Managing Assignments ### Reassigning Numbers To move a phone number to a different campaign: 1. Open the phone number details 2. Select the new campaign 3. Save changes > [!WARNING] > > Reassigning a number takes effect immediately. Ensure the new campaign is appropriate for your messaging use case. ### Removing Assignment To remove a phone number from a campaign: 1. Open the phone number details 2. Clear the campaign selection 3. Save changes > [!NOTE] > > Unassigned US phone numbers will have limited throughput and may experience carrier filtering. ## Troubleshooting ### Number not appearing for assignment * Verify the phone number is a US number * Check the phone number status is active * Ensure you have an approved campaign ### Assignment not taking effect * Wait a few minutes for carrier propagation * Verify the campaign is still approved * Check your account balance for any issues ### Low throughput after assignment Your throughput is determined by your brand's Trust Score: | Trust Score | Daily Limit | Messages/Second | | - | - | - | | Low | 2,000 | 0.2 | | Medium | 10,000 | 1 | | High | 200,000 | 10+ | ## Best Practices 1. **Dedicated numbers per use case** - Use separate numbers for different message types 2. **Geographic distribution** - Use local area codes when possible 3. **Monitor delivery rates** - Track message delivery in your dashboard 4. **Keep campaigns updated** - Update sample messages if your content changes ## Summary The complete 10DLC registration flow: ``` 1. Complete Business Verification (KYB) ↓ 2. Register Brand ($4 one-time) → Wait for approval (1-3 days) ↓ 3. Create Campaign ($15 one-time + $10/month) → Wait for approval (1-7 days) ↓ 4. Assign Phone Numbers → Ready to send! ``` > [card] [10DLC Overview](/guides/10dlc/overview) > > Review the full process > [card] [Brand Registration](/guides/10dlc/brand-registration) > > Start with brand registration # 10DLC for Sub-Accounts Source: https://docs.zavu.dev/guides/10dlc/sub-accounts Register independent 10DLC brands and campaigns for each of your sub-accounts ## 10DLC for Sub-Accounts If you use **sub-accounts** to manage multiple clients, each client that sends SMS in the United States needs their own 10DLC registration. Since each sub-account represents a different business with its own legal entity, they cannot share a single brand registration. ## How It Works Each sub-account (project) can independently register: * **Its own Brand** — with the client's legal business information * **Its own Campaigns** — with the client's specific messaging use cases * **Its own Phone Numbers** — assigned to the client's approved campaigns ``` Parent Account ├── Sub-Account A (Client A) │ ├── Brand: "Acme Corp" │ ├── Campaign: "Order Notifications" │ └── Phone: +1 (555) 111-0001 ├── Sub-Account B (Client B) │ ├── Brand: "Beta LLC" │ ├── Campaign: "2FA Codes" │ └── Phone: +1 (555) 222-0002 └── Sub-Account C (Client C) ├── Brand: "Gamma Inc" ├── Campaign: "Marketing" └── Phone: +1 (555) 333-0003 ``` ## Prerequisites 1. **Active sub-account** with an API key ([create one](/guides/sub-accounts/creating)) 2. **Business Verification (KYB)** approved on the parent account (submitting a brand or campaign without it returns `403 kyb_required`) 3. **Client's business information** (legal name, EIN, address, website) ## Step-by-Step Registration Use the **sub-account's API key** for all requests below. This ensures the brand and campaign are scoped to that specific sub-account. ### 1. Register the Client's Brand ```bash theme={null} # Using the sub-account's API key curl -X POST https://api.zavu.dev/v1/10dlc/brands \ -H "Authorization: Bearer $SUB_ACCOUNT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "entityType": "PRIVATE_PROFIT", "displayName": "Acme Corp", "companyName": "Acme Corporation", "ein": "12-3456789", "email": "compliance@acme.com", "phone": "+14155551234", "street": "123 Main St", "city": "San Francisco", "state": "CA", "postalCode": "94102", "country": "US", "website": "https://acme.com", "vertical": "Technology" }' ``` ### 2. Submit Brand for Review ```bash theme={null} curl -X POST https://api.zavu.dev/v1/10dlc/brands/{brandId}/submit \ -H "Authorization: Bearer $SUB_ACCOUNT_API_KEY" ``` Wait 1-3 business days for brand verification. Check status: ```bash theme={null} curl -X POST https://api.zavu.dev/v1/10dlc/brands/{brandId}/sync \ -H "Authorization: Bearer $SUB_ACCOUNT_API_KEY" ``` ### 3. Create a Campaign Once the brand is verified: ```bash theme={null} curl -X POST https://api.zavu.dev/v1/10dlc/campaigns \ -H "Authorization: Bearer $SUB_ACCOUNT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "brandId": "brand_abc123", "name": "Order Notifications", "useCase": "ACCOUNT_NOTIFICATION", "description": "Send order status updates to customers who opted in at checkout.", "sampleMessages": [ "Hi {{name}}, your order #{{order_id}} has shipped!", "Your order #{{order_id}} has been delivered." ], "subscriberOptIn": true, "subscriberOptOut": true, "subscriberHelp": true, "numberPooling": false, "directLending": false, "embeddedLink": true, "embeddedPhone": false, "affiliateMarketing": false, "ageGated": false }' ``` ### 4. Submit Campaign for Review ```bash theme={null} curl -X POST https://api.zavu.dev/v1/10dlc/campaigns/{campaignId}/submit \ -H "Authorization: Bearer $SUB_ACCOUNT_API_KEY" ``` ### 5. Assign Phone Numbers After the campaign is approved, assign the sub-account's phone numbers: ```bash theme={null} curl -X POST https://api.zavu.dev/v1/10dlc/campaigns/{campaignId}/phone-numbers \ -H "Authorization: Bearer $SUB_ACCOUNT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "phoneNumberId": "pn_abc123" }' ``` ## Managing via Dashboard Team owners can also manage 10DLC registration for each sub-account through the dashboard: 1. Navigate to the sub-account in your [Dashboard](https://dashboard.zavu.dev) 2. Switch to the sub-account's project context 3. Go to **Compliance** → **10DLC** 4. Follow the standard registration flow ## Cost All 10DLC fees are charged from the **parent team's balance**: | Item | Cost | | - | - | | Brand registration per sub-account | \$4 one-time | | Campaign registration per sub-account | $15 one-time ($2 for `LOW_VOLUME`) | | Campaign monthly fee per sub-account | $10/month ($2 for `LOW_VOLUME`) | > [!NOTE] > > Each sub-account registers its own brand and campaign, so each pays these fees once. ## Best Practices * **Register early** — Brand and campaign approval can take up to 7 business days * **Use accurate business info** — Mismatches between the brand and official records cause rejections * **One brand per sub-account** — Each sub-account represents one business entity * **Set credit limits** — Use [credit limits](/guides/sub-accounts/managing) to control spending per sub-account ## Next Steps > [card] [Sub-Accounts Overview](/guides/sub-accounts/overview) > > Learn about sub-accounts > [card] [10DLC Overview](/guides/10dlc/overview) > > Full 10DLC registration guide # Conversational Flows Source: https://docs.zavu.dev/guides/ai-agents/flows Build structured conversation paths for lead capture, booking, and multi-step processes ## Conversational Flows Flows are deterministic conversation paths that guide users through structured interactions. While the AI agent handles free-form questions, flows ensure consistent data collection and multi-step processes. ## What are Flows? A **Flow** is a series of steps that execute in sequence when triggered. Each step can: * Send a message * Collect and validate user input * Branch based on conditions * Call webhook tools * Generate AI responses * Transfer to a human agent ``` [Trigger] → [Welcome] → [Collect Name] → [Collect Email] → [Confirm] → [End] ``` ## When to Use Flows | Scenario | Use Flow | Use Free-form LLM | | - | - | - | | Collecting required data (name, email, phone) | Yes | No | | Compliance-required messaging | Yes | No | | Appointment booking | Yes | Maybe | | General Q\&A | No | Yes | | Product recommendations | No | Yes | | Order status lookup | Maybe | Yes | > [!TIP] > > Use flows when you need **guaranteed outcomes**. Use the LLM when you need **flexibility**. ## Flow Triggers Three trigger types run today: | Trigger Type | Description | Example | | - | - | - | | `keyword` | Message contains any of the words, case-insensitive substring match | "book", "appointment", "schedule" | | `intent` | Message expresses the intent you described, judged by a classifier rather than by matching words | "wants a price or a quote" | | `always` | Every inbound message that is not already inside a flow | Welcome/onboarding flow | `always` restarts once the flow completes, so the contact's next message begins it again. It is not once per conversation. ### When a flow says nothing A flow that ends on the message that started it, without replying, hands that message to the agent, and the agent answers it. A flow that only decides it has nothing to do does not leave the contact unanswered. Three things are silence on purpose, and stay silent: * A [`transfer` step](#transfer-step), even with an empty `message`. This is how you keep the agent quiet while a person handles a contact. * An `llm` step that chose not to reply. * A flow that already spoke. Once a flow has sent the contact something, the exchange is the flow's: if it later ends on a quiet step, the agent does not step in on that message. > [!WARNING] > > An `always` flow that replies claims every message, so the agent behind it never > answers. That is the right shape for a bot that is only a flow, and the wrong > one for a gate in front of an agent. ### Intent triggers Write `trigger.intent` as one plain sentence describing what the contact wants: "wants a price or a quote", "asks where their order is". Any language works, and the contact does not have to write in the same one. A single word such as "sales" is accepted, but a sentence leaves less to guess. ```json theme={null} { "type": "intent", "intent": "asks where their order is" } ``` It is read for meaning, not for words. "I don't want pricing info, stop writing to me" contains the word and does not start a pricing flow. A `keyword` trigger on "pricing" would. What decides which flow starts: * **Priority still comes first.** A `keyword` or `always` flow with a higher `priority` wins without the intent being considered. An `always` flow above your intent flows means they never start; give intent flows the higher number. * **An intent flow starts only on a clear match.** When no intent matches, the next `keyword` or `always` flow in priority order starts. If there is none, the agent answers. * **At most 12 intent flows are considered per message**, the 12 with the highest priority. An intent longer than 300 characters is cut at 300. * **If the classifier is unavailable, the message is handled as if no intent matched.** The contact still gets an answer; the intent flow does not start. * Reading the intent adds time before the reply, typically under a second, and only on messages that no higher-priority trigger claimed. Agents without intent flows are unaffected. A bare "STOP" or an order number carries no intent to read. Use a `keyword` trigger for exact words. > [!WARNING] > > `manual` is accepted by the API and stored on the flow, and nothing starts it: a > flow created with it is never triggered and never says so. Use `keyword`, > `intent` or `always`. ## Step Types Every step has the same envelope. `config` is required, and its fields depend on `type`: ```json theme={null} { "id": "", "type": "message | collect | condition | tool | llm | transfer", "config": { }, "nextStepId": "" } ``` That is the envelope, not a runnable step: `config` is empty because what goes in it depends on `type`. Each section below gives the real shape. Steps run in array order unless a step names `nextStepId`, or a `condition` sends the run elsewhere. A step with no `nextStepId` that is last in the array completes the flow. ### Message Step Send a fixed message. `{{variables}}` are replaced with what earlier steps collected. ```json theme={null} { "id": "welcome", "type": "message", "config": { "text": "Welcome to Acme! How can I help you today?" }, "nextStepId": "ask_email" } ``` ### Collect Step Ask for something and wait for the customer's next message. ```json theme={null} { "id": "ask_email", "type": "collect", "config": { "variable": "customer_email", "prompt": "What's your email address?", "validation": { "type": "email" }, "errorMessage": "That does not look like an email address. Try again." }, "nextStepId": "ask_size" } ``` `errorMessage` sits beside `validation`, not inside it. **Validation types** | `type` | Extra fields | Accepts | | - | - | - | | `text` | `minLength`, `maxLength` | any text | | `email` | | a valid address | | `phone` | | a valid phone number | | `number` | `min`, `max` | numeric input | | `date` | | **accepts anything**, see below | | `choice` | `options` (required) | one of `options` | `date` currently accepts whatever the contact typed, including `tomorrow` and `asdf`, and `minDate` / `maxDate` are not read. Validate a date in your own handler, or constrain it with `choice`. `choice` is the reason to reach for a flow at all: the answer is constrained to your list, so what reaches your systems is one of a known set rather than whatever the customer typed. ```json theme={null} { "id": "ask_size", "type": "collect", "config": { "variable": "size", "prompt": "How many people are on your team? Reply 1-10, 11-50, 51-200, or 200+.", "validation": { "type": "choice", "options": ["1-10", "11-50", "51-200", "200+"] }, "errorMessage": "Please pick one of: 1-10, 11-50, 51-200, 200+." }, "nextStepId": "route" } ``` ### Condition Step Branch. `if` is interpolated first, then compared, so `"{{budget}} > 10000"` becomes `"25000 > 10000"`. ```json theme={null} { "id": "route", "type": "condition", "config": { "conditions": [ { "if": "{{size}} == 200+", "goto": "enterprise_path" } ], "defaultGoto": "standard_path" } } ``` Supported operators are `==`, `!=`, `>` and `<`. There is no `AND`, no `OR` and no parentheses. For anything more, chain several condition steps or move the decision into a tool. `defaultGoto` runs when no condition matches. Without it, and with nothing matching, the flow completes. If it has said nothing so far, the agent answers the message instead ([when a flow says nothing](#when-a-flow-says-nothing)). A `condition` cannot be the first step unless it has a `defaultGoto`: creating or updating a flow that way returns `400 invalid_request`. Nothing has been collected at the first step, so its conditions come out the same on every message. `{{ }}` reads only variables collected earlier in the same flow, by a `collect` step or a `storeResultAs`. Contact fields and metadata are not available: `{{contact.plan}}` is not replaced, and a condition written on it never matches. A name is letters, digits and underscores, with no dots, so `{{lead.status}}` does not reach inside a stored object either. To branch on something you hold about the contact, look it up in a `tool` step whose result is a plain value (a string, a number or a boolean) and compare that. ### Tool Step Call one of the agent's tools. ```json theme={null} { "id": "save_lead", "type": "tool", "config": { "toolName": "create_lead", "params": { "email": "{{customer_email}}", "size": "{{size}}", "source": "whatsapp" }, "storeResultAs": "lead" }, "nextStepId": "confirm" } ``` > [!NOTE] > > Use `config.toolName`. `config.toolId` is the original spelling and still works: > either field is accepted, and either one may hold the tool's name or its id. > > A step pointing at a tool the agent does not have used to reply > `Tool "tool_9f2a..." not found` to the customer as an ordinary message. It now > ends the session and logs the misconfiguration instead. If the flow had not said > anything yet, the plain agent answers that message; otherwise it takes the > conversation from the next one. > > The session ends as **abandoned**, not completed: the flow never reached the end > you designed. Check that status if your completion numbers look better than the > conversations do. `params` values wrapped in `{{ }}` are replaced with collected data; anything else is passed through literally. (`action` appears in older examples and is accepted, but nothing reads it, so it is left out here.) `params` may be omitted for a tool that takes none. The tool must already exist on the agent before the flow runs. ### LLM Step Let the model answer, inside the flow. ```json theme={null} { "id": "suggest", "type": "llm", "config": { "systemPrompt": "Suggest three products for a team of {{size}}. Be brief.", "collectVariable": "recommendations" }, "nextStepId": "confirm" } ``` ### Transfer Step End the flow and hand off to a human. ```json theme={null} { "id": "escalate", "type": "transfer", "config": { "message": "I'm connecting you with a specialist. Please hold." } } ``` This sends `message`, marks the session `transferred`, and **silences the agent for that contact**. Their next messages are stored and appear in your inbox, and the agent does not answer them: a contact who asked for a person is not talked over by the bot while they wait. **The agent takes the thread back as soon as someone answers.** Any outbound message you send to that contact, from the inbox or the API, ends the handoff. There is no button to press and no state to clean up. > [!WARNING] > > **Nobody is notified for you.** There is no queue and no assignment: the > conversation waits in the inbox until a person opens it. If your team does not > watch the inbox, put a `tool` step before the `transfer` and notify yourself > from your own systems. > > `notifyWebhook` and `reason` are accepted on the config and never read. ## Variables `{{name}}` in any string is replaced with what an earlier step collected. A variable that was never collected renders as the literal `{{name}}` in the message the customer receives, so check that every one is populated before it is used. Variables come from three places: | Step type | Field that creates the variable | | - | - | | `collect` | `config.variable` | | `tool` | `config.storeResultAs` | | `llm` | `config.collectVariable` | ## Via Dashboard **1. Navigate to Flows** Go to **Senders** > select your sender > **Agent** tab > **Flows** section. **2. Create New Flow** Click **Create Flow** and enter: * **Name**: A descriptive name (e.g., "Lead Capture Flow") * **Description**: What this flow does * **Trigger**: How the flow is activated **3. Add Steps** Use the visual flow builder to add steps: 1. Click **Add Step** 2. Select the step type 3. Configure the step parameters 4. Connect steps by dragging between nodes **4. Configure Triggers** Set up how the flow is triggered: * For **keyword** triggers, enter the words that activate the flow * For **intent** triggers, describe in one sentence what the contact wants * For **always**, the flow runs on every inbound message that is not already inside a flow **5. Test the Flow** Use the **Test** button to simulate a conversation and verify each step works correctly. **6. Activate Flow** Toggle the flow to **Active** to enable it for incoming messages. ## Via API ### Create Flow ```typescript TypeScript theme={null} import Zavudev from "@zavudev/sdk"; const zavu = new Zavudev({ apiKey: process.env["ZAVUDEV_API_KEY"] }); const flow = await zavu.senders.agent.flows.create("sender_abc123", { name: "Lead Capture", description: "Collect lead information from new contacts", trigger: { type: "keyword", keywords: ["interested", "info", "learn more"], }, steps: [ { id: "welcome", type: "message", config: { text: "Great! I'd love to help. Let me collect some information." }, nextStepId: "get_name", }, { id: "get_name", type: "collect", config: { variable: "customer_name", prompt: "What's your name?" }, nextStepId: "get_email", }, { id: "get_email", type: "collect", config: { variable: "customer_email", prompt: "What's your email address?", validation: { type: "email" }, errorMessage: "That does not look like an email address. Try again.", }, nextStepId: "confirm", }, { id: "confirm", type: "message", config: { text: "Thanks {{customer_name}}! Our team will contact you at {{customer_email}} within 24 hours.", }, }, ], }); console.log("Flow created:", flow.id); ``` ```python Python theme={null} import os from zavudev import Zavudev zavu = Zavudev(api_key=os.environ.get("ZAVUDEV_API_KEY")) flow = zavu.senders.agent.flows.create( "sender_abc123", name="Lead Capture", description="Collect lead information from new contacts", trigger={"type": "keyword", "keywords": ["interested", "info", "learn more"]}, steps=[ { "id": "welcome", "type": "message", "config": {"text": "Great! I'd love to help. Let me collect some information."}, "nextStepId": "get_name", }, { "id": "get_name", "type": "collect", "config": {"variable": "customer_name", "prompt": "What's your name?"}, "nextStepId": "get_email", }, { "id": "get_email", "type": "collect", "config": { "variable": "customer_email", "prompt": "What's your email address?", "validation": {"type": "email"}, "errorMessage": "That does not look like an email address. Try again.", }, "nextStepId": "confirm", }, { "id": "confirm", "type": "message", "config": { "text": "Thanks {{customer_name}}! Our team will contact you at {{customer_email}} within 24 hours." }, }, ], ) print(f"Flow created: {flow.id}") ``` ```ruby Ruby theme={null} require "zavudev" client = Zavudev::Client.new(api_key: ENV["ZAVUDEV_API_KEY"]) flow = client.senders.agent.flows.create("sender_abc123", name: "Lead Capture", description: "Collect lead information from new contacts", trigger: { type: "keyword", keywords: ["interested", "info", "learn more"] }, steps: [ { id: "welcome", type: "message", config: { text: "Great! I'd love to help. Let me collect some information." }, nextStepId: "get_name" }, { id: "get_name", type: "collect", config: { variable: "customer_name", prompt: "What's your name?" }, nextStepId: "get_email" }, { id: "get_email", type: "collect", config: { variable: "customer_email", prompt: "What's your email address?", validation: { type: "email" }, errorMessage: "That does not look like an email address. Try again." }, nextStepId: "confirm" }, { id: "confirm", type: "message", config: { text: "Thanks {{customer_name}}! Our team will contact you at {{customer_email}} within 24 hours." } } ] ) puts "Flow created: #{flow.id}" ``` ```go Go theme={null} package main import ( "context" "fmt" "os" "github.com/zavudev/sdk-go" "github.com/zavudev/sdk-go/option" ) func main() { client := zavudev.NewClient(option.WithAPIKey(os.Getenv("ZAVUDEV_API_KEY"))) flow, err := client.Senders.Agent.Flows.New(context.TODO(), "sender_abc123", zavudev.FlowNewParams{ Name: zavudev.F("Lead Capture"), Description: zavudev.F("Collect lead information from new contacts"), Trigger: zavudev.F(zavudev.FlowTriggerParam{ Type: zavudev.F(zavudev.FlowTriggerTypeKeyword), Keywords: zavudev.F([]string{"interested", "info", "learn more"}), }), Steps: zavudev.F([]zavudev.FlowStepParam{ { ID: zavudev.F("welcome"), Type: zavudev.F(zavudev.FlowStepTypeMessage), Config: zavudev.F(map[string]interface{}{"text": "Great! I'd love to help. Let me collect some information."}), NextStepID: zavudev.F("get_name"), }, { ID: zavudev.F("get_name"), Type: zavudev.F(zavudev.FlowStepTypeCollect), Config: zavudev.F(map[string]interface{}{ "variable": "customer_name", "prompt": "What's your name?", }), NextStepID: zavudev.F("get_email"), }, { ID: zavudev.F("get_email"), Type: zavudev.F(zavudev.FlowStepTypeCollect), Config: zavudev.F(map[string]interface{}{ "variable": "customer_email", "prompt": "What's your email address?", "validation": map[string]interface{}{"type": "email"}, "errorMessage": "That does not look like an email address. Try again.", }), NextStepID: zavudev.F("confirm"), }, { ID: zavudev.F("confirm"), Type: zavudev.F(zavudev.FlowStepTypeMessage), Config: zavudev.F(map[string]interface{}{"text": "Thanks {{customer_name}}! Our team will contact you at {{customer_email}} within 24 hours."}), }, }), }) if err != nil { panic(err) } fmt.Printf("Flow created: %s\n", flow.ID) } ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/senders/sender_abc123/agent/flows \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Lead Capture", "description": "Collect lead information from new contacts", "trigger": { "type": "keyword", "keywords": ["interested", "info", "learn more"] }, "steps": [ { "id": "welcome", "type": "message", "config": { "text": "Great! I would love to help. Let me collect some information." }, "nextStepId": "get_name" }, { "id": "get_name", "type": "collect", "config": { "variable": "customer_name", "prompt": "What is your name?" }, "nextStepId": "get_email" }, { "id": "get_email", "type": "collect", "config": { "variable": "customer_email", "prompt": "What is your email address?", "validation": { "type": "email" }, "errorMessage": "That does not look like an email address. Try again." }, "nextStepId": "confirm" }, { "id": "confirm", "type": "message", "config": { "text": "Thanks {{customer_name}}! Our team will contact you at {{customer_email}} within 24 hours." } } ] }' ``` > [!NOTE] > > A flow is created **disabled** unless the request sends `"enabled": true`, so the examples above create a flow that does not run yet. Send `enabled: true` when creating it, or turn it on with [Update Flow](#update-flow). ### List Flows ```typescript TypeScript theme={null} const flows = await zavu.senders.agent.flows.list("sender_abc123"); for (const flow of flows.items) { console.log(`${flow.name} (${flow.id}) - ${flow.enabled ? "Active" : "Inactive"}`); } ``` ```python Python theme={null} flows = zavu.senders.agent.flows.list("sender_abc123") for flow in flows.items: status = "Active" if flow.enabled else "Inactive" print(f"{flow.name} ({flow.id}) - {status}") ``` ```ruby Ruby theme={null} flows = client.senders.agent.flows.list("sender_abc123") flows.items.each do |flow| status = flow.enabled ? "Active" : "Inactive" puts "#{flow.name} (#{flow.id}) - #{status}" end ``` ```go Go theme={null} flows, _ := client.Senders.Agent.Flows.List(context.TODO(), "sender_abc123", zavudev.FlowListParams{}) for _, flow := range flows.Items { status := "Inactive" if flow.Enabled { status = "Active" } fmt.Printf("%s (%s) - %s\n", flow.Name, flow.ID, status) } ``` ```php PHP theme={null} $flows = $client->senders->agent->flows->list('sender_abc123'); foreach ($flows->items as $flow) { $status = $flow->enabled ? 'Active' : 'Inactive'; echo "{$flow->name} ({$flow->id}) - {$status}\n"; } ``` ```bash cURL theme={null} curl https://api.zavu.dev/v1/senders/sender_abc123/agent/flows \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` ### Update Flow ```typescript TypeScript theme={null} const flow = await zavu.senders.agent.flows.update( "flow_xyz789", { senderId: "sender_abc123", name: "Updated Lead Capture", enabled: true, } ); ``` ```python Python theme={null} flow = zavu.senders.agent.flows.update( "sender_abc123", "flow_xyz789", name="Updated Lead Capture", enabled=True, ) ``` ```ruby Ruby theme={null} flow = client.senders.agent.flows.update("flow_xyz789", sender_id: "sender_abc123", name: "Updated Lead Capture", enabled: true ) ``` ```go Go theme={null} flow, _ := client.Senders.Agent.Flows.Update(context.TODO(), "flow_xyz789", zavudev.FlowUpdateParams{ SenderID: zavudev.String("sender_abc123"), Name: zavudev.String("Updated Lead Capture"), Enabled: zavudev.Bool(true), }) ``` ```php PHP theme={null} $flow = $client->senders->agent->flows->update('sender_abc123', 'flow_xyz789', [ 'name' => 'Updated Lead Capture', 'enabled' => true, ]); ``` ```bash cURL theme={null} curl -X PATCH https://api.zavu.dev/v1/senders/sender_abc123/agent/flows/flow_xyz789 \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "Updated Lead Capture", "enabled": true}' ``` ## Example Flows ### Lead Capture Flow Qualify an inbound lead into three known fields before anyone reads the thread. ```json theme={null} { "name": "Lead Capture", "trigger": { "type": "keyword", "keywords": ["pricing", "demo", "info"] }, "enabled": true, "priority": 10, "steps": [ { "id": "intro", "type": "message", "config": { "text": "Thanks for your interest! Let me get some details." }, "nextStepId": "get_name" }, { "id": "get_name", "type": "collect", "config": { "variable": "name", "prompt": "What's your name?" }, "nextStepId": "get_email" }, { "id": "get_email", "type": "collect", "config": { "variable": "email", "prompt": "What's your email?", "validation": { "type": "email" }, "errorMessage": "That does not look like an email address. Try again." }, "nextStepId": "get_size" }, { "id": "get_size", "type": "collect", "config": { "variable": "size", "prompt": "How many employees? Reply 1-10, 11-50, 51-200 or 200+.", "validation": { "type": "choice", "options": ["1-10", "11-50", "51-200", "200+"] }, "errorMessage": "Please reply with 1-10, 11-50, 51-200 or 200+." }, "nextStepId": "save_lead" }, { "id": "save_lead", "type": "tool", "config": { "toolName": "create_lead", "params": { "name": "{{name}}", "email": "{{email}}", "companySize": "{{size}}" } }, "nextStepId": "confirm" }, { "id": "confirm", "type": "message", "config": { "text": "Thanks {{name}}! A team member will reach out within 24 hours." } } ] } ``` The `choice` step is what makes this worth a flow: `size` reaching your CRM is always one of four values, not whatever the customer typed. ### Appointment Booking Flow ```json theme={null} { "name": "Appointment Booking", "trigger": { "type": "keyword", "keywords": ["book", "appointment", "schedule"] }, "enabled": true, "priority": 10, "steps": [ { "id": "intro", "type": "message", "config": { "text": "I'd be happy to help you book an appointment!" }, "nextStepId": "get_service" }, { "id": "get_service", "type": "collect", "config": { "variable": "service_type", "prompt": "What type of appointment? Reply consultation, follow-up or review.", "validation": { "type": "choice", "options": ["consultation", "follow-up", "review"] }, "errorMessage": "Please reply with consultation, follow-up or review." }, "nextStepId": "get_date" }, { "id": "get_date", "type": "collect", "config": { "variable": "preferred_date", "prompt": "What date works best?", "validation": { "type": "date" } }, "nextStepId": "check_slots" }, { "id": "check_slots", "type": "tool", "config": { "toolName": "get_available_slots", "params": { "date": "{{preferred_date}}", "service": "{{service_type}}" }, "storeResultAs": "slots" }, "nextStepId": "show_slots" }, { "id": "show_slots", "type": "message", "config": { "text": "Available times on {{preferred_date}}: {{slots}}" }, "nextStepId": "get_time" }, { "id": "get_time", "type": "collect", "config": { "variable": "selected_time", "prompt": "Which time works for you?" }, "nextStepId": "book" }, { "id": "book", "type": "tool", "config": { "toolName": "create_appointment", "params": { "service": "{{service_type}}", "date": "{{preferred_date}}", "time": "{{selected_time}}" } }, "nextStepId": "confirm" }, { "id": "confirm", "type": "message", "config": { "text": "Your {{service_type}} appointment is booked for {{preferred_date}} at {{selected_time}}. See you then!" } } ] } ``` Both `toolName` values above must match tools that already exist: `create_lead`, `get_available_slots` and `create_appointment` must already exist on the agent. ## Flow Sessions When a user enters a flow, a **session** is created to track their progress. Sessions store: * Current step * Collected variables * Timestamps * Channel information > [!WARNING] > > **Sessions do not expire.** A contact who stops answering halfway keeps an open > session, and their next message resumes that flow at the step they left, weeks > later, with the variables they had. It is not restarted, and there is no > timeout. > > Plan for it: give a long flow a way out, and do not assume a `collect` step is > answered by the message that follows it in the same hour. A flow that ends in a > `transfer` or completes cleanly closes its session, which is the reliable way > to stop one. > > There is also no API for reading sessions. The status is visible in the > dashboard. ## Best Practices > [card] Keep Flows Short > > Aim for 5-7 steps maximum. Long flows have higher abandonment rates. > [card] Validate Early > > Validate critical inputs (email, phone) immediately after collection. > [card] Provide Exit Points > > Let users say "stop" or "cancel" to exit the flow at any time. > [card] Use Variables > > Reference collected data in messages to personalize the experience. ## Next Steps > [card] [Add Tools](/guides/ai-agents/tools) > > Enable your flows to execute actions via webhooks > [card] [Knowledge Base](/guides/ai-agents/knowledge-base) > > Let your agent answer questions from documents # Knowledge Base Source: https://docs.zavu.dev/guides/ai-agents/knowledge-base Upload documents and FAQs so your AI agent can answer questions accurately ## From the CLI ```bash theme={null} npx zavudev agents knowledge-bases create --sender --name "Product docs" npx zavudev agents knowledge-bases documents add --sender --kb \ --title "Pricing" --content-file ./pricing.md npx zavudev agents knowledge-bases documents list --sender --kb ``` Processing takes a few seconds; `isProcessed` flips to true and `chunkCount` fills in. Verify retrieval actually fires with `npx zavudev agents test` — it reports how many chunks the answer used. > [!WARNING] > > A prompt that says "only state what the documentation returns" with no documents > attached does not refuse. It invents. Attach the documents before relying on > that instruction. ## Knowledge Base A Knowledge Base lets your AI agent answer questions using your own documents. Upload FAQs, product docs, policies, or any text content, and the agent will search for relevant information when responding to customers. ## What is a Knowledge Base? A **Knowledge Base** is a collection of documents that your agent can reference. When a customer asks a question, the agent: 1. Searches the knowledge base for relevant content 2. Retrieves the most relevant chunks 3. Includes that context in its prompt 4. Generates an informed response This approach is called **RAG** (Retrieval Augmented Generation). ## Use Cases | Use Case | Documents to Upload | | - | - | | **Customer Support** | FAQs, troubleshooting guides, help articles | | **Product Information** | Product specs, features, pricing | | **Policy Questions** | Terms of service, privacy policy, refund policy | | **Technical Support** | Documentation, API guides, tutorials | | **Sales** | Product comparisons, case studies, benefits | ## Via Dashboard **1. Navigate to Knowledge Bases** Go to **Senders** > select your sender > **Agent** tab > **Knowledge Bases** section. **2. Create Knowledge Base** Click **Create Knowledge Base** and enter: * **Name**: A descriptive name (e.g., "Product FAQs") * **Description**: What this knowledge base contains **3. Add Documents** Click **Add Document** and choose how to add content: * **Text**: Paste text content directly * **Markdown**: Upload `.md` files * **PDF**: Upload PDF documents * **URL**: Import content from a webpage **4. Wait for Processing** Documents are automatically chunked and embedded. This takes a few seconds for small documents, longer for large PDFs. > [!NOTE] > > You'll see a processing indicator while chunks are being created. The agent can only use fully processed documents. **5. Verify** Check the document list to see: * **Chunk Count**: Number of searchable chunks created * **Processing Status**: Whether the document is ready ## Via API ### Create Knowledge Base ```typescript TypeScript theme={null} import Zavudev from "@zavudev/sdk"; const zavu = new Zavudev({ apiKey: process.env["ZAVUDEV_API_KEY"], }); const kb = await zavu.senders.agent.knowledgeBases.create("sender_abc123", { name: "Product FAQs", description: "Frequently asked questions about our products", }); console.log("Knowledge Base created:", kb.id); ``` ```python Python theme={null} import os from zavudev import Zavudev zavu = Zavudev( api_key=os.environ.get("ZAVUDEV_API_KEY"), ) kb = zavu.senders.agent.knowledge_bases.create( "sender_abc123", name="Product FAQs", description="Frequently asked questions about our products", ) print(f"Knowledge Base created: {kb.id}") ``` ```ruby Ruby theme={null} require "zavudev" client = Zavudev::Client.new(api_key: ENV["ZAVUDEV_API_KEY"]) kb = client.senders.agent.knowledge_bases.create("sender_abc123", name: "Product FAQs", description: "Frequently asked questions about our products" ) puts "Knowledge Base created: #{kb.id}" ``` ```go Go theme={null} kb, _ := client.Senders.Agent.KnowledgeBases.New(context.TODO(), "sender_abc123", zavudev.KnowledgeBaseCreateParams{ Name: zavudev.String("Product FAQs"), Description: zavudev.String("Frequently asked questions about our products"), }) fmt.Printf("Knowledge Base created: %s\n", kb.ID) ``` ```php PHP theme={null} $kb = $client->senders->agent->knowledgeBases->create('sender_abc123', [ 'name' => 'Product FAQs', 'description' => 'Frequently asked questions about our products', ]); echo "Knowledge Base created: {$kb->id}\n"; ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/senders/sender_abc123/agent/knowledge-bases \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Product FAQs", "description": "Frequently asked questions about our products" }' ``` ### Add Document ```typescript TypeScript theme={null} const document = await zavu.senders.agent.knowledgeBases.documents.create( "sender_abc123", "kb_xyz789", { title: "Return Policy", content: `# Return Policy ## 30-Day Returns All products can be returned within 30 days of purchase for a full refund. ## Conditions - Items must be unused and in original packaging - Original receipt required - Shipping costs are non-refundable ## How to Return 1. Contact support@example.com 2. Receive return shipping label 3. Ship item within 7 days 4. Refund processed within 5 business days ## Exceptions - Final sale items cannot be returned - Custom orders are non-refundable `, } ); console.log("Document added:", document.id); ``` ```python Python theme={null} document = zavu.senders.agent.knowledge_bases.documents.create( "sender_abc123", "kb_xyz789", title="Return Policy", content="""# Return Policy ## 30-Day Returns All products can be returned within 30 days of purchase for a full refund. ## Conditions - Items must be unused and in original packaging - Original receipt required - Shipping costs are non-refundable ## How to Return 1. Contact support@example.com 2. Receive return shipping label 3. Ship item within 7 days 4. Refund processed within 5 business days ## Exceptions - Final sale items cannot be returned - Custom orders are non-refundable """, ) print(f"Document added: {document.id}") ``` ```ruby Ruby theme={null} document = client.senders.agent.knowledge_bases.documents.create("sender_abc123", "kb_xyz789", title: "Return Policy", content: "# Return Policy\n\n## 30-Day Returns\nAll products can be returned within 30 days of purchase for a full refund.\n\n## Conditions\n- Items must be unused and in original packaging\n- Original receipt required\n- Shipping costs are non-refundable\n\n## How to Return\n1. Contact support@example.com\n2. Receive return shipping label\n3. Ship item within 7 days\n4. Refund processed within 5 business days\n\n## Exceptions\n- Final sale items cannot be returned\n- Custom orders are non-refundable" ) puts "Document added: #{document.id}" ``` ```go Go theme={null} document, _ := client.Senders.Agent.KnowledgeBases.Documents.New(context.TODO(), "sender_abc123", "kb_xyz789", zavudev.DocumentCreateParams{ Title: zavudev.String("Return Policy"), Content: zavudev.String("# Return Policy\n\n## 30-Day Returns\nAll products can be returned within 30 days of purchase for a full refund.\n\n## Conditions\n- Items must be unused and in original packaging\n- Original receipt required\n- Shipping costs are non-refundable\n\n## How to Return\n1. Contact support@example.com\n2. Receive return shipping label\n3. Ship item within 7 days\n4. Refund processed within 5 business days\n\n## Exceptions\n- Final sale items cannot be returned\n- Custom orders are non-refundable"), }) fmt.Printf("Document added: %s\n", document.ID) ``` ```php PHP theme={null} $document = $client->senders->agent->knowledgeBases->documents->create('sender_abc123', 'kb_xyz789', [ 'title' => 'Return Policy', 'content' => "# Return Policy\n\n## 30-Day Returns\nAll products can be returned within 30 days of purchase for a full refund.\n\n## Conditions\n- Items must be unused and in original packaging\n- Original receipt required\n- Shipping costs are non-refundable\n\n## How to Return\n1. Contact support@example.com\n2. Receive return shipping label\n3. Ship item within 7 days\n4. Refund processed within 5 business days\n\n## Exceptions\n- Final sale items cannot be returned\n- Custom orders are non-refundable", ]); echo "Document added: {$document->id}\n"; ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/senders/sender_abc123/agent/knowledge-bases/kb_xyz789/documents \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "title": "Return Policy", "content": "# Return Policy\n\n## 30-Day Returns\nAll products can be returned within 30 days..." }' ``` ### List Knowledge Bases ```typescript TypeScript theme={null} const kbs = await zavu.senders.agent.knowledgeBases.list("sender_abc123"); for (const kb of kbs.items) { console.log(`${kb.name}: ${kb.documentCount} documents, ${kb.totalChunks} chunks`); } ``` ```python Python theme={null} kbs = zavu.senders.agent.knowledge_bases.list("sender_abc123") for kb in kbs.items: print(f"{kb.name}: {kb.document_count} documents, {kb.total_chunks} chunks") ``` ```ruby Ruby theme={null} kbs = client.senders.agent.knowledge_bases.list("sender_abc123") kbs.items.each do |kb| puts "#{kb.name}: #{kb.document_count} documents, #{kb.total_chunks} chunks" end ``` ```go Go theme={null} kbs, _ := client.Senders.Agent.KnowledgeBases.List(context.TODO(), "sender_abc123", zavudev.KnowledgeBaseListParams{}) for _, kb := range kbs.Items { fmt.Printf("%s: %d documents, %d chunks\n", kb.Name, kb.DocumentCount, kb.TotalChunks) } ``` ```php PHP theme={null} $kbs = $client->senders->agent->knowledgeBases->list('sender_abc123'); foreach ($kbs->items as $kb) { echo "{$kb->name}: {$kb->documentCount} documents, {$kb->totalChunks} chunks\n"; } ``` ```bash cURL theme={null} curl https://api.zavu.dev/v1/senders/sender_abc123/agent/knowledge-bases \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` ### List Documents ```typescript TypeScript theme={null} const docs = await zavu.senders.agent.knowledgeBases.documents.list( "sender_abc123", "kb_xyz789" ); for (const doc of docs.items) { console.log(`${doc.title}: ${doc.chunkCount} chunks`); } ``` ```python Python theme={null} docs = zavu.senders.agent.knowledge_bases.documents.list( "sender_abc123", "kb_xyz789" ) for doc in docs.items: print(f"{doc.title}: {doc.chunk_count} chunks") ``` ```ruby Ruby theme={null} docs = client.senders.agent.knowledge_bases.documents.list("sender_abc123", "kb_xyz789") docs.items.each do |doc| puts "#{doc.title}: #{doc.chunk_count} chunks" end ``` ```go Go theme={null} docs, _ := client.Senders.Agent.KnowledgeBases.Documents.List(context.TODO(), "sender_abc123", "kb_xyz789", zavudev.DocumentListParams{}) for _, doc := range docs.Items { fmt.Printf("%s: %d chunks\n", doc.Title, doc.ChunkCount) } ``` ```php PHP theme={null} $docs = $client->senders->agent->knowledgeBases->documents->list('sender_abc123', 'kb_xyz789'); foreach ($docs->items as $doc) { echo "{$doc->title}: {$doc->chunkCount} chunks\n"; } ``` ```bash cURL theme={null} curl https://api.zavu.dev/v1/senders/sender_abc123/agent/knowledge-bases/kb_xyz789/documents \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` ### Delete Document ```typescript TypeScript theme={null} await zavu.senders.agent.knowledgeBases.documents.delete( "sender_abc123", "kb_xyz789", "doc_abc123" ); ``` ```python Python theme={null} zavu.senders.agent.knowledge_bases.documents.delete( "sender_abc123", "kb_xyz789", "doc_abc123" ) ``` ```ruby Ruby theme={null} client.senders.agent.knowledge_bases.documents.delete("sender_abc123", "kb_xyz789", "doc_abc123") ``` ```go Go theme={null} client.Senders.Agent.KnowledgeBases.Documents.Delete(context.TODO(), "sender_abc123", "kb_xyz789", "doc_abc123") ``` ```php PHP theme={null} $client->senders->agent->knowledgeBases->documents->delete('sender_abc123', 'kb_xyz789', 'doc_abc123'); ``` ```bash cURL theme={null} curl -X DELETE https://api.zavu.dev/v1/senders/sender_abc123/agent/knowledge-bases/kb_xyz789/documents/doc_abc123 \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` ## How RAG Works ``` Customer: "What's your return policy?" | v +------------------+ | Embed Question | | Create vector | +------------------+ | v +------------------+ | Vector Search | | Find similar | | chunks | +------------------+ | v +------------------+ | Retrieve Top | | Chunks (e.g., 3) | +------------------+ | v +------------------+ | Inject Context | | into LLM Prompt | +------------------+ | v +------------------+ | Generate Answer | | with Context | +------------------+ | v Customer: "You can return any item within 30 days for a full refund. Items must be unused and in original packaging. Contact support@..." ``` ### Processing Steps 1. **Chunking**: Documents are split into smaller pieces (\~500-1000 tokens each) 2. **Embedding**: Each chunk is converted to a vector using an embedding model 3. **Indexing**: Vectors are stored for fast similarity search 4. **Retrieval**: When a question arrives, we find the most similar chunks 5. **Generation**: Retrieved chunks are included in the LLM prompt as context ## What you can put in, and where Which formats are accepted depends on the surface, and the API is the narrowest of the three. It takes text and nothing else. | Source | Dashboard | API / SDK | CLI | | - | - | - | - | | Text or Markdown pasted in | yes | yes — `content` | yes — `--content` / `--content-file` | | A file: `.pdf`, `.docx`, `.doc`, `.odt`, `.xlsx`, `.xls`, `.csv`, `.txt`, `.md`, `.json` | yes | no | no | | A web page, by URL | yes | no | no | The API's `POST /v1/senders/{senderId}/agent/knowledge-bases/{kbId}/documents` takes `title` and `content`, both strings. There is no upload endpoint for knowledge-base files and no URL parameter: to load a PDF or a page from your own code, extract the text yourself and send it as `content`. A document knows where it came from — `sourceType` and `sourceUrl` are stored and returned, so one imported from the web can be told apart from one that was pasted. They are not yet declared in the OpenAPI schema, so a generated SDK will not type them; read them from the raw response until it is. > [!WARNING] > > File extraction is text only. Images and charts are not read, and a table is > flattened into the surrounding text. ### Importing a web page In the dashboard, open the agent, go to **Knowledge**, choose a knowledge base, then **Add document → URL**. It reads that one page — not the site — and files it under a knowledge base named after the host, so several pages of the same site group together. Add the pages that answer questions one at a time: shipping, returns, pricing. The onboarding assistant can do the same thing if you paste a link into the chat. This adds to what the agent knows. It does not change the agent's prompt, its model or its channels. ## Document Limits | Limit | Value | | - | - | | Max `content` per document, API | 500,000 characters | | Max characters kept from an imported web page | 40,000 | | Documents added by one website import | 30 | | Chunks added by one website import | 1,500 | The last three are the website importer's caps. Documents you create through the API or the dashboard are bounded by `content` length, and by nothing else today: there is no enforced ceiling on documents per knowledge base or on knowledge bases per agent, so plan capacity from content size rather than from a document count. ## Best Practices > [card] Structure Content > > Use headers, bullet points, and clear sections. Well-structured content creates better chunks. > [card] Be Specific > > Include specific answers to common questions. The more explicit, the better the retrieval. > [card] Keep Current > > Update documents when information changes. Outdated content leads to incorrect answers. > [card] Separate Topics > > Create separate documents for different topics. This improves retrieval accuracy. ### Content Writing Tips **Good document structure:** ```markdown theme={null} # Product Returns ## How long do I have to return an item? You have 30 days from the delivery date to return any item for a full refund. ## What condition must items be in? Items must be unused, unworn, and in original packaging with all tags attached. ## How do I start a return? 1. Email support@example.com with your order number 2. We'll send a prepaid return label within 24 hours 3. Ship the item within 7 days 4. Refund processes within 5 business days of receipt ``` **Poor document structure:** ```text theme={null} Returns are processed within the timeframe specified in our terms. Contact support for assistance with any issues you may have regarding your order. ``` > [!TIP] > > Write documents as if you're answering specific customer questions. This makes retrieval more accurate. ## Example Documents ### FAQ Document ```markdown theme={null} # Shipping FAQs ## How long does shipping take? - Standard shipping: 5-7 business days - Express shipping: 2-3 business days - Same-day delivery: Available in select cities ## How much does shipping cost? - Free shipping on orders over $50 - Standard shipping: $5.99 - Express shipping: $12.99 ## Do you ship internationally? Yes! We ship to over 50 countries. International shipping takes 7-14 business days. ## Can I track my order? Yes, you'll receive a tracking number via email once your order ships. ``` ### Product Document ```markdown theme={null} # Pro Widget X100 ## Overview The Pro Widget X100 is our flagship widget designed for professional use. ## Specifications - Weight: 2.5 lbs - Dimensions: 10" x 6" x 4" - Battery life: 12 hours - Warranty: 2 years ## Features - Wireless connectivity (Bluetooth 5.0 and WiFi) - Water-resistant (IP67 rating) - Voice control compatible ## Price - Standard edition: $299 - Pro edition: $449 (includes accessories) ## Common Questions ### Is it compatible with Mac? Yes, works with macOS 10.15 and later. ### Can I use it outdoors? Yes, the IP67 rating means it's water and dust resistant. ``` ## Next Steps > [card] [Setup Guide](/guides/ai-agents/setup) > > Configure your AI agent settings > [card] [Add Tools](/guides/ai-agents/tools) > > Let your agent execute actions > [card] [Create Flows](/guides/ai-agents/flows) > > Build structured conversation paths > [card] [AI Agents Concept](/concepts/ai-agents) > > Learn how agents work under the hood # AI Agents Overview Source: https://docs.zavu.dev/guides/ai-agents/overview Automate customer conversations with intelligent AI-powered agents ## AI Agents Overview AI Agents allow you to automate customer conversations using large language models. When a customer sends a message to your sender, the agent can respond intelligently, answer questions from your knowledge base, and execute actions via webhooks. ## When to Use AI Agents AI Agents are ideal for: | Use Case | Description | | - | - | | **Customer Support** | Answer common questions, troubleshoot issues, and escalate complex cases | | **Lead Qualification** | Collect contact information and qualify leads through conversation | | **Appointment Booking** | Guide customers through scheduling via conversational flows | | **FAQ Answering** | Provide instant answers using your knowledge base documents | | **Order Status** | Look up orders and provide updates via webhook integrations | ## Supported Providers Configure your agent with any of these LLM providers: | Provider | Models | Notes | | - | - | - | | **OpenAI** | gpt-4o, gpt-4o-mini, gpt-4-turbo | Most popular, great general-purpose | | **Anthropic** | claude-3-5-sonnet, claude-3-5-haiku | Excellent reasoning capabilities | | **Google** | gemini-2.5-pro, gemini-2.5-flash, gemini-2.5-flash-lite | Cost-effective for high volume | | **Mistral** | mistral-large, mistral-small | European data residency option | | **Zavu** | Managed models | No API key needed, billed to your account | > [!NOTE] > > Using the **Zavu** provider means we handle the LLM API calls for you. Costs are charged directly to your Zavu balance at pass-through rates with no markup. ## Quick Start Here's how to create an AI agent for a sender: ```typescript TypeScript theme={null} import Zavudev from "@zavudev/sdk"; const zavu = new Zavudev({ apiKey: process.env["ZAVUDEV_API_KEY"], }); // Create an agent for your sender const agent = await zavu.senders.agent.create("sender_abc123", { enabled: true, provider: "openai", model: "gpt-4o-mini", systemPrompt: `You are a helpful customer support agent for Acme Corp. Be friendly and concise. If you don't know the answer, say so. For billing questions, ask the customer to email billing@acme.com.`, temperature: 0.7, contextWindowMessages: 10, triggerOnChannels: ["sms", "whatsapp"], }); console.log("Agent created:", agent.id); ``` ```python Python theme={null} import os from zavudev import Zavudev zavu = Zavudev( api_key=os.environ.get("ZAVUDEV_API_KEY"), ) # Create an agent for your sender agent = zavu.senders.agent.create( "sender_abc123", enabled=True, provider="openai", model="gpt-4o-mini", system_prompt="""You are a helpful customer support agent for Acme Corp. Be friendly and concise. If you don't know the answer, say so. For billing questions, ask the customer to email billing@acme.com.""", temperature=0.7, context_window_messages=10, trigger_on_channels=["sms", "whatsapp"], ) print(f"Agent created: {agent.id}") ``` ```ruby Ruby theme={null} require "zavudev" client = Zavudev::Client.new(api_key: ENV["ZAVUDEV_API_KEY"]) agent = client.senders.agent.create("sender_abc123", enabled: true, provider: "openai", model: "gpt-4o-mini", system_prompt: "You are a helpful customer support agent for Acme Corp.\nBe friendly and concise. If you don't know the answer, say so.\nFor billing questions, ask the customer to email billing@acme.com.", temperature: 0.7, context_window_messages: 10, trigger_on_channels: ["sms", "whatsapp"] ) puts "Agent created: #{agent.id}" ``` ```go Go theme={null} package main import ( "context" "fmt" "os" "github.com/zavudev/sdk-go" ) func main() { client := zavudev.NewClient(zavudev.WithAPIKey(os.Getenv("ZAVUDEV_API_KEY"))) agent, _ := client.Senders.Agent.New(context.TODO(), "sender_abc123", zavudev.AgentCreateParams{ Enabled: zavudev.Bool(true), Provider: zavudev.String("openai"), Model: zavudev.String("gpt-4o-mini"), SystemPrompt: zavudev.String("You are a helpful customer support agent for Acme Corp.\nBe friendly and concise. If you don't know the answer, say so.\nFor billing questions, ask the customer to email billing@acme.com."), Temperature: zavudev.Float(0.7), ContextWindowMessages: zavudev.Int(10), TriggerOnChannels: []string{"sms", "whatsapp"}, }) fmt.Printf("Agent created: %s\n", agent.ID) } ``` ```php PHP theme={null} $client = new Zavudev\Client(apiKey: getenv('ZAVUDEV_API_KEY')); $agent = $client->senders->agent->create('sender_abc123', [ 'enabled' => true, 'provider' => 'openai', 'model' => 'gpt-4o-mini', 'systemPrompt' => "You are a helpful customer support agent for Acme Corp.\nBe friendly and concise. If you don't know the answer, say so.\nFor billing questions, ask the customer to email billing@acme.com.", 'temperature' => 0.7, 'contextWindowMessages' => 10, 'triggerOnChannels' => ['sms', 'whatsapp'], ]); echo "Agent created: {$agent->id}\n"; ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/senders/sender_abc123/agent \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "enabled": true, "provider": "openai", "model": "gpt-4o-mini", "systemPrompt": "You are a helpful customer support agent for Acme Corp.\nBe friendly and concise. If you dont know the answer, say so.\nFor billing questions, ask the customer to email billing@acme.com.", "temperature": 0.7, "contextWindowMessages": 10, "triggerOnChannels": ["sms", "whatsapp"] }' ``` ## Agent Architecture When a message arrives, the agent processes it through this flow: ``` Inbound Message | v +---------------+ | Agent Enabled?| +---------------+ | | No Yes | | v v +---------+ +-----------------+ | Webhook | | Check Flow State| | Only | +-----------------+ +---------+ | | Has Active No Active Flow Flow | | v v +---------+ +-----------+ | Execute | | Match Flow| | Step | | Triggers | +---------+ +-----------+ | | | No Match | | v v +---------+ +-----------+ | Send | | Query KB | |Response | | + LLM Call| +---------+ +-----------+ | v +-----------+ | Send | | Response | +-----------+ ``` ### Key Components > [card] LLM Configuration > > Choose your AI provider and model. Configure system prompts, temperature, and context window size. > [card] Conversational Flows > > Create structured conversation paths for lead capture, booking, and multi-step processes. > [card] Webhook Tools > > Let the AI execute actions by calling your webhooks (check orders, create tickets, etc.). > [card] Knowledge Base > > Upload documents and FAQs. The agent uses RAG to find relevant context when answering. ## Pricing AI Agent costs are **pass-through** with no Zavu markup. You pay exactly what the providers charge: | Provider | Model | Input Cost | Output Cost | \~Cost per Message | | - | - | - | - | - | | OpenAI | gpt-5.6-luna | \$0.20/1M tokens | \$1.20/1M tokens | \~\$0.0004 | | OpenAI | gpt-4o-mini | \$0.15/1M tokens | \$0.60/1M tokens | \~\$0.0002 | | OpenAI | gpt-4o | \$2.50/1M tokens | \$10/1M tokens | \~\$0.004 | | Anthropic | claude-3-5-haiku | \$0.80/1M tokens | \$4/1M tokens | \~\$0.0014 | | Anthropic | claude-3-5-sonnet | \$3/1M tokens | \$15/1M tokens | \~\$0.005 | | Google | gemini-2.5-flash-lite | \$0.10/1M tokens | \$0.40/1M tokens | \~\$0.0002 | | Google | gemini-2.5-flash | \$0.30/1M tokens | \$2.50/1M tokens | \~\$0.0007 | `~Cost per Message` assumes roughly 1,000 input and 150 output tokens. Provider prices move: the agent's model picker in the dashboard lists the catalog currently in force, and is what billing charges against. > [!TIP] > > New agents on the **Zavu** provider start on **gpt-5.6-luna** (`openai/gpt-5.6-luna`). **gpt-4o-mini** and **gemini-2.5-flash-lite** cost less per token. Reserve larger models for complex reasoning tasks. ## Next Steps > [card] [Setup Guide](/guides/ai-agents/setup) > > Configure your first AI Agent step-by-step > [card] [Conversational Flows](/guides/ai-agents/flows) > > Build structured conversation paths > [card] [Webhook Tools](/guides/ai-agents/tools) > > Add custom actions via webhooks > [card] [Knowledge Base](/guides/ai-agents/knowledge-base) > > Set up RAG with your documents # Setup Guide Source: https://docs.zavu.dev/guides/ai-agents/setup Configure your first AI Agent via Dashboard or API ## AI Agent Setup Guide This guide walks you through setting up an AI Agent for your sender. You can configure agents through the Dashboard UI or programmatically via the API. ## Agents have their own ids An agent is a standalone object: it can answer on several senders, and it can exist with none while you build it. ```bash theme={null} npx zavudev agents list # every agent in the project, with its id ``` | | | | - | - | | `GET /v1/agents` | List, including agents connected to no sender | | `POST /v1/agents` | Create standalone — no sender required | | `GET /v1/agents/{agentId}` | Fetch one | | `PATCH /v1/agents/{agentId}` | Update | | `DELETE /v1/agents/{agentId}` | Delete | | `POST /v1/agents/{agentId}/test` | Run it and return the reply | | `POST /v1/agents/{agentId}/senders` | Connect a sender | | `DELETE /v1/agents/{agentId}/senders/{senderId}` | Disconnect a sender | The `/v1/senders/{senderId}/agent` routes still work. They resolve a sender to the one agent that answers on it: the agent created on it, else the agent connected to it with `POST /v1/agents/{agentId}/senders`. They cannot reach an agent with no sender. `POST /v1/agents` takes `apiKey` the same way the sender-scoped create does, so a standalone agent can use any provider. ```bash theme={null} npx zavudev agents senders connect --agent --sender npx zavudev agents senders disconnect --agent --sender ``` > [!NOTE] > > A sender answers with at most one agent. Connecting one that is already in use > returns `400` naming the agent that holds it — otherwise you would have an agent > that looks connected and never receives a message. ## Test it without sending anything Once the agent exists, run it and read what it would say. Nothing is delivered and no execution is logged. A test run is billed like a live reply: on a `zavu` provider agent it is charged to your balance at the same per-token price (the response's `cost`); on a bring-your-own-key agent it costs your provider. ```bash theme={null} npx zavudev agents list # agents in the project, with their ids npx zavudev agents test --agent --message "where is order ORD-001?" ``` It prints the reply plus tokens, latency, and how many knowledge-base chunks were retrieved. The model is offered the agent's tools but none runs: if it picks one, the reply stops at that choice. Pass `executeTools` on the API to run them for real. It also warns about what a dry run cannot prove: an agent that is still disabled, a sender without the channels the agent triggers on, and contact metadata that exists on a real conversation but not here. Multi-turn, and isolating the prompt from retrieval: ```bash theme={null} npx zavudev agents test --agent \ --turn "I need to change my booking" --turn "Sure — which one?" \ --message "the one on Friday" npx zavudev agents test --agent --message "what do you cost?" --no-knowledge ``` ## Via Dashboard **1. Navigate to Agent Settings** Go to **Senders** in the sidebar, select your sender, and click the **Agent** tab. **2. Enable the Agent** Toggle the **Enable Agent** switch to activate automated responses for this sender. **3. Configure LLM Provider** Choose your AI provider and model: * **Provider**: Select OpenAI, Anthropic, Google, Mistral, or Zavu (managed) * **Model**: Choose from available models (e.g., gpt-4o-mini, claude-3-5-haiku) * **API Key**: Enter your provider API key (not needed for Zavu provider) **4. Write System Prompt** The system prompt defines your agent's personality and behavior. Write clear instructions: ```text theme={null} You are a helpful customer support agent for [Company Name]. Guidelines: - Be friendly and professional - Keep responses concise (under 160 characters for SMS) - If you don't know the answer, say so - For billing issues, direct customers to billing@company.com - Never share sensitive information ``` **5. Set Trigger Channels** Select which channels should trigger the agent: * **SMS**: Text messages * **WhatsApp**: WhatsApp messages > [!NOTE] > > You can enable the agent on both channels or just one. **6. Configure Advanced Settings** Adjust these optional settings: | Setting | Description | Default | | - | - | - | | Temperature | Creativity level (0-2) | 0.7 | | Max Tokens | Maximum response length | 500 | | Context Window | Previous messages to include | 10 | **7. Save and Test** Click **Save** to apply your configuration. Send a test message to your sender's phone number to verify the agent responds correctly. ## Via API ### Create Agent A sender answers with one agent. Creating one on a sender that already answers with an agent (created on it, or connected to it) returns `409 conflict`. ```typescript TypeScript theme={null} import Zavudev from "@zavudev/sdk"; const zavu = new Zavudev({ apiKey: process.env["ZAVUDEV_API_KEY"], }); const agent = await zavu.senders.agent.create("sender_abc123", { enabled: true, provider: "openai", model: "gpt-4o-mini", apiKey: process.env.OPENAI_API_KEY, // Not needed for Zavu provider systemPrompt: `You are a helpful customer support agent for Acme Corp. Be friendly and concise. If you don't know the answer, say so.`, temperature: 0.7, maxTokens: 500, contextWindowMessages: 10, triggerOnChannels: ["sms", "whatsapp"], }); console.log("Agent ID:", agent.id); ``` ```python Python theme={null} import os from zavudev import Zavudev zavu = Zavudev( api_key=os.environ.get("ZAVUDEV_API_KEY"), ) agent = zavu.senders.agent.create( "sender_abc123", enabled=True, provider="openai", model="gpt-4o-mini", api_key=os.environ.get("OPENAI_API_KEY"), # Not needed for Zavu provider system_prompt="""You are a helpful customer support agent for Acme Corp. Be friendly and concise. If you don't know the answer, say so.""", temperature=0.7, max_tokens=500, context_window_messages=10, trigger_on_channels=["sms", "whatsapp"], ) print(f"Agent ID: {agent.id}") ``` ```ruby Ruby theme={null} require "zavudev" client = Zavudev::Client.new(api_key: ENV["ZAVUDEV_API_KEY"]) agent = client.senders.agent.create("sender_abc123", enabled: true, provider: "openai", model: "gpt-4o-mini", api_key: ENV["OPENAI_API_KEY"], system_prompt: "You are a helpful customer support agent for Acme Corp.\nBe friendly and concise. If you don't know the answer, say so.", temperature: 0.7, max_tokens: 500, context_window_messages: 10, trigger_on_channels: ["sms", "whatsapp"] ) puts "Agent ID: #{agent.id}" ``` ```go Go theme={null} agent, _ := client.Senders.Agent.New(context.TODO(), "sender_abc123", zavudev.AgentCreateParams{ Enabled: zavudev.Bool(true), Provider: zavudev.String("openai"), Model: zavudev.String("gpt-4o-mini"), APIKey: zavudev.String(os.Getenv("OPENAI_API_KEY")), SystemPrompt: zavudev.String("You are a helpful customer support agent for Acme Corp.\nBe friendly and concise."), Temperature: zavudev.Float(0.7), MaxTokens: zavudev.Int(500), ContextWindowMessages: zavudev.Int(10), TriggerOnChannels: []string{"sms", "whatsapp"}, }) fmt.Printf("Agent ID: %s\n", agent.ID) ``` ```php PHP theme={null} $agent = $client->senders->agent->create('sender_abc123', [ 'enabled' => true, 'provider' => 'openai', 'model' => 'gpt-4o-mini', 'apiKey' => getenv('OPENAI_API_KEY'), 'systemPrompt' => "You are a helpful customer support agent for Acme Corp.\nBe friendly and concise.", 'temperature' => 0.7, 'maxTokens' => 500, 'contextWindowMessages' => 10, 'triggerOnChannels' => ['sms', 'whatsapp'], ]); echo "Agent ID: {$agent->id}\n"; ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/senders/sender_abc123/agent \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "enabled": true, "provider": "openai", "model": "gpt-4o-mini", "apiKey": "sk-...", "systemPrompt": "You are a helpful customer support agent for Acme Corp.\nBe friendly and concise.", "temperature": 0.7, "maxTokens": 500, "contextWindowMessages": 10, "triggerOnChannels": ["sms", "whatsapp"] }' ``` ### Update Agent ```typescript TypeScript theme={null} const agent = await zavu.senders.agent.update("sender_abc123", { systemPrompt: "Updated system prompt...", temperature: 0.5, model: "gpt-4o", // Upgrade to more powerful model }); ``` ```python Python theme={null} agent = zavu.senders.agent.update( "sender_abc123", system_prompt="Updated system prompt...", temperature=0.5, model="gpt-4o", # Upgrade to more powerful model ) ``` ```ruby Ruby theme={null} agent = client.senders.agent.update("sender_abc123", system_prompt: "Updated system prompt...", temperature: 0.5, model: "gpt-4o" ) ``` ```go Go theme={null} agent, _ := client.Senders.Agent.Update(context.TODO(), "sender_abc123", zavudev.AgentUpdateParams{ SystemPrompt: zavudev.String("Updated system prompt..."), Temperature: zavudev.Float(0.5), Model: zavudev.String("gpt-4o"), }) ``` ```php PHP theme={null} $agent = $client->senders->agent->update('sender_abc123', [ 'systemPrompt' => 'Updated system prompt...', 'temperature' => 0.5, 'model' => 'gpt-4o', ]); ``` ```bash cURL theme={null} curl -X PATCH https://api.zavu.dev/v1/senders/sender_abc123/agent \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "systemPrompt": "Updated system prompt...", "temperature": 0.5, "model": "gpt-4o" }' ``` ### Enable/Disable Agent ```typescript TypeScript theme={null} // Disable agent await zavu.senders.agent.update("sender_abc123", { enabled: false, }); // Re-enable agent await zavu.senders.agent.update("sender_abc123", { enabled: true, }); ``` ```python Python theme={null} # Disable agent zavu.senders.agent.update("sender_abc123", enabled=False) # Re-enable agent zavu.senders.agent.update("sender_abc123", enabled=True) ``` ```ruby Ruby theme={null} # Disable agent client.senders.agent.update("sender_abc123", enabled: false) # Re-enable agent client.senders.agent.update("sender_abc123", enabled: true) ``` ```go Go theme={null} // Disable agent client.Senders.Agent.Update(context.TODO(), "sender_abc123", zavudev.AgentUpdateParams{ Enabled: zavudev.Bool(false), }) // Re-enable agent client.Senders.Agent.Update(context.TODO(), "sender_abc123", zavudev.AgentUpdateParams{ Enabled: zavudev.Bool(true), }) ``` ```php PHP theme={null} // Disable agent $client->senders->agent->update('sender_abc123', ['enabled' => false]); // Re-enable agent $client->senders->agent->update('sender_abc123', ['enabled' => true]); ``` ```bash cURL theme={null} # Disable agent curl -X PATCH https://api.zavu.dev/v1/senders/sender_abc123/agent \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{"enabled": false}' ``` ### Get Agent Configuration ```typescript TypeScript theme={null} const agent = await zavu.senders.agent.retrieve("sender_abc123"); console.log("Provider:", agent.provider); console.log("Model:", agent.model); console.log("Enabled:", agent.enabled); console.log("Stats:", agent.stats); ``` ```python Python theme={null} agent = zavu.senders.agent.retrieve("sender_abc123") print(f"Provider: {agent.provider}") print(f"Model: {agent.model}") print(f"Enabled: {agent.enabled}") print(f"Stats: {agent.stats}") ``` ```ruby Ruby theme={null} agent = client.senders.agent.retrieve("sender_abc123") puts "Provider: #{agent.provider}" puts "Model: #{agent.model}" puts "Enabled: #{agent.enabled}" puts "Stats: #{agent.stats}" ``` ```go Go theme={null} agent, _ := client.Senders.Agent.Get(context.TODO(), "sender_abc123") fmt.Printf("Provider: %s\n", agent.Provider) fmt.Printf("Model: %s\n", agent.Model) fmt.Printf("Enabled: %v\n", agent.Enabled) fmt.Printf("Stats: %+v\n", agent.Stats) ``` ```php PHP theme={null} $agent = $client->senders->agent->retrieve('sender_abc123'); echo "Provider: {$agent->provider}\n"; echo "Model: {$agent->model}\n"; echo "Enabled: {$agent->enabled}\n"; echo "Stats: " . json_encode($agent->stats) . "\n"; ``` ```bash cURL theme={null} curl https://api.zavu.dev/v1/senders/sender_abc123/agent \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` A sender with no agent returns `404`. ## Configuration Options | Option | Type | Description | | - | - | - | | `enabled` | boolean | Whether the agent responds to messages | | `provider` | string | LLM provider: `openai`, `anthropic`, `google`, `mistral`, `zavu` | | `model` | string | Model ID (e.g., `gpt-4o-mini`, `claude-3-5-haiku`) | | `apiKey` | string | Provider API key (not needed for `zavu` provider) | | `systemPrompt` | string | Instructions that define the agent's behavior | | `temperature` | number | Response creativity (0 = deterministic, 2 = creative) | | `maxTokens` | number | Maximum tokens in the response | | `contextWindowMessages` | number | Number of previous messages to include as context | | `triggerOnChannels` | array | Channels that activate the agent: `["sms", "whatsapp"]` | ## System Prompt Best Practices > [card] Be Specific > > Define exactly what the agent should and shouldn't do. Include examples of good responses. > [card] Set Boundaries > > Specify what topics to avoid and when to escalate to humans. > [card] Match the Channel > > For SMS, instruct the agent to keep responses under 160 characters when possible. > [card] Include Context > > Mention your company name, products, and common customer questions. ### Example System Prompts **Customer Support Agent:** ```text theme={null} You are a friendly customer support agent for TechCo. Your responsibilities: - Answer questions about our products and services - Help troubleshoot common issues - Direct billing questions to billing@techco.com - Escalate complex technical issues to our support team Guidelines: - Be concise and helpful - Use simple language - Never share customer account details - If unsure, say "I'll connect you with a specialist" ``` **Lead Qualification Agent:** ```text theme={null} You are a sales assistant for RealEstate Inc. Your goal is to qualify leads by collecting: 1. Their name 2. Type of property they're looking for 3. Budget range 4. Preferred location 5. Timeline for purchase Be friendly and conversational. After collecting this info, let them know an agent will contact them within 24 hours. ``` ## Testing Your Agent After configuring your agent, test it thoroughly: 1. **Send test messages** from different phone numbers 2. **Test edge cases** like questions the agent shouldn't answer 3. **Verify escalation** works correctly 4. **Check response quality** across different topics 5. **Monitor logs** in the Dashboard under Agent > Execution Logs > [!WARNING] > > Always test your agent before enabling it for production traffic. Poorly configured agents can frustrate customers. ## Next Steps > [card] [Add Flows](/guides/ai-agents/flows) > > Create structured conversation paths > [card] [Add Tools](/guides/ai-agents/tools) > > Let your agent execute actions > [card] [Add Knowledge Base](/guides/ai-agents/knowledge-base) > > Upload documents for the agent to reference > [card] [AI Agents Concept](/concepts/ai-agents) > > Learn how agents work under the hood # Tool library Source: https://docs.zavu.dev/guides/ai-agents/tool-library Skills Zavu hosts and runs, with no webhook to build A [webhook tool](/guides/ai-agents/tools) is powerful and it is also an afternoon of work: an endpoint to write, somewhere to host it, a secret to verify. That work sits between "I made an agent" and "my agent booked a meeting", and it is where most people stop. Library skills have no webhook. Zavu runs them. You add one from the agent's **Tools** tab and it works on a phone call and in a thread, with the same behaviour on both. ## What is available | Skill | What it does | Needs | | - | - | - | | `check_availability` | Reads open slots from your connected calendar | A calendar | | `book_meeting` | Books a slot and sends the invitation | A calendar | > [!NOTE] > > Library skills are added from the dashboard. The REST API and the CLI manage > webhook tools, which is what `npx zavudev agents tools list` shows. ## Connect a calendar Both booking skills read one calendar per project. Open the agent's **Tools** tab, then **Library**, then **Connect one** on either skill. ### Cal.com You need two things from Cal.com: * an **API key** — Settings, then Developer, then API keys * the **event type id** — the number in the event type's own URL Cal.com keeps its own availability rules, buffers and limits. Zavu asks it for open slots and books them; it never second-guesses them. ### Google Calendar Connecting is an OAuth grant. Zavu asks for three scopes: read your busy times, write the events the agent books, and your account's email address, which is what labels the connection in the dashboard so two calendars are tellable apart. Nothing else. Your working window is yours to set, because Google has no concept of one: the agent will only offer times inside the days and hours you configure. > [!WARNING] > > Times are resolved in the time zone on the connection, and the agent always says > the zone out loud. Set it to the business's zone, not yours, if they differ. ## What the agent does with them `check_availability` returns open slots and the zone they are in. `book_meeting` takes one of those slots plus a name and an email. The important part is what happens when a booking is not accepted. A Cal.com event type that requires confirmation answers `pending`, which is **not** a booking. The skill tells the agent exactly that, and the agent says the time is held but not confirmed. An agent that reports `pending` as booked sends someone to a meeting that may never exist, which is the worst outcome available on that call. The same rule covers the rest: no calendar connected, a slot in the past, a calendar that cannot be reached. Each one comes back as something the agent can say, never as a silent success. ## Draft a custom tool from a description For anything the library does not cover you still write a webhook, but you do not have to write the schema. In **Tools**, **Custom tool**, describe what it should do: ``` Look up an order by its number and return the status and delivery date ``` You get a name, a description written for the model, and a parameter schema, all filled into the form for you to review before saving. Whatever it changed to make the definition valid is listed underneath: a renamed parameter, a nested object flattened to text, a required field dropped because it was not declared. It does not invent the endpoint. Where the tool sends its arguments is the one thing only you know, and a guessed URL produces a tool that looks finished and does nothing. ## Where to go next > [card] [Webhook tools](/guides/ai-agents/tools) > > Build a tool against your own endpoint. > [card] [Voice agents](/guides/voice-agents/overview) > > Where skills matter most, because the caller is waiting. # Webhook Tools Source: https://docs.zavu.dev/guides/ai-agents/tools Let your AI agent execute actions by calling your webhooks ## Webhook Tools Tools allow your AI agent to execute actions by calling your webhooks. The LLM decides when to use a tool based on the conversation context, executes the call, and uses the result in its response. > [!NOTE] > > **Tools run on every channel** — plain text, voice, and inside a flow's `tool` > step. A text agent asked to look something up calls the tool and answers with > the result; the model may chain up to 5 tool rounds per reply. Flows remain > useful when you need deterministic sequencing rather than model-decided calls > ([flows](/guides/ai-agents/flows)). > > `npx zavudev agents test` warns you when an agent has tools its channels will never > call. > [!NOTE] > > **Before you build one:** `check_availability` and `book_meeting` already exist > and Zavu runs them, so a booking agent needs no endpoint at all. See the > [tool library](/guides/ai-agents/tool-library). This page is for the rest, where > the logic is yours. ## What are Agent Tools? A **Tool** is a webhook endpoint that your agent can call to perform actions or retrieve data. When you define a tool, you specify: * **Name**: How the agent refers to the tool * **Description**: When the agent should use it * **Parameters**: What data to send (JSON Schema) * **Webhook URL**: Your endpoint that handles the request The AI model uses the description to decide when to invoke the tool automatically. ## Use Cases | Tool | Description | Example Use | | - | - | - | | `check_order_status` | Look up order tracking info | "Where is my order #12345?" | | `create_support_ticket` | Open a support ticket | "I need help with my account" | | `book_appointment` | Schedule a calendar event | "Book me for next Tuesday at 2pm" | | `get_inventory` | Check product availability | "Do you have the blue shirt in medium?" | | `lookup_customer` | Retrieve customer data | "What's my account balance?" | | `process_refund` | Initiate a refund | "I want a refund for order #123" | ## Via Dashboard **1. Navigate to Tools** Go to **Senders** > select your sender > **Agent** tab > **Tools** section. **2. Create New Tool** Click **Create Tool** and fill in the form: * **Name**: A short identifier (e.g., `check_order_status`) * **Description**: When should the agent use this tool? Be specific. * **Webhook URL**: Your HTTPS endpoint **3. Define Parameters** Specify the parameters your webhook expects: ```json theme={null} { "type": "object", "properties": { "order_id": { "type": "string", "description": "The order ID to look up" } }, "required": ["order_id"] } ``` **4. Configure Security (Optional)** Add a **Webhook Secret** to verify requests are from Zavu. We'll sign each request with this secret. **5. Test the Tool** Use the **Test** button to send a sample request to your webhook and verify it responds correctly. **6. Enable the Tool** Toggle the tool to **Enabled** so the agent can use it. ## Via API ### Create Tool ```typescript TypeScript theme={null} import Zavudev from "@zavudev/sdk"; const zavu = new Zavudev({ apiKey: process.env["ZAVUDEV_API_KEY"], }); const tool = await zavu.senders.agent.tools.create("sender_abc123", { name: "check_order_status", description: "Look up the status and tracking information for a customer order. Use this when a customer asks about their order status, shipping, or delivery.", parameters: { type: "object", properties: { order_id: { type: "string", description: "The order ID (e.g., ORD-12345)", }, }, required: ["order_id"], }, webhookUrl: "https://api.yourcompany.com/webhooks/order-status", webhookSecret: "whsec_your_secret_here", // Optional }); console.log("Tool created:", tool.id); ``` ```python Python theme={null} import os from zavudev import Zavudev zavu = Zavudev( api_key=os.environ.get("ZAVUDEV_API_KEY"), ) tool = zavu.senders.agent.tools.create( "sender_abc123", name="check_order_status", description="Look up the status and tracking information for a customer order. Use this when a customer asks about their order status, shipping, or delivery.", parameters={ "type": "object", "properties": { "order_id": { "type": "string", "description": "The order ID (e.g., ORD-12345)", }, }, "required": ["order_id"], }, webhook_url="https://api.yourcompany.com/webhooks/order-status", webhook_secret="whsec_your_secret_here", # Optional ) print(f"Tool created: {tool.id}") ``` ```ruby Ruby theme={null} require "zavudev" client = Zavudev::Client.new(api_key: ENV["ZAVUDEV_API_KEY"]) tool = client.senders.agent.tools.create("sender_abc123", name: "check_order_status", description: "Look up the status and tracking information for a customer order. Use this when a customer asks about their order status, shipping, or delivery.", parameters: { type: "object", properties: { order_id: { type: "string", description: "The order ID (e.g., ORD-12345)" } }, required: ["order_id"] }, webhook_url: "https://api.yourcompany.com/webhooks/order-status", webhook_secret: "whsec_your_secret_here" # Optional ) puts "Tool created: #{tool.id}" ``` ```go Go theme={null} tool, _ := client.Senders.Agent.Tools.New(context.TODO(), "sender_abc123", zavudev.ToolCreateParams{ Name: zavudev.String("check_order_status"), Description: zavudev.String("Look up the status and tracking information for a customer order. Use this when a customer asks about their order status, shipping, or delivery."), Parameters: &zavudev.ToolParameters{ Type: zavudev.String("object"), Properties: map[string]interface{}{ "order_id": map[string]interface{}{ "type": "string", "description": "The order ID (e.g., ORD-12345)", }, }, Required: []string{"order_id"}, }, WebhookURL: zavudev.String("https://api.yourcompany.com/webhooks/order-status"), WebhookSecret: zavudev.String("whsec_your_secret_here"), // Optional }) fmt.Printf("Tool created: %s\n", tool.ID) ``` ```php PHP theme={null} $tool = $client->senders->agent->tools->create('sender_abc123', [ 'name' => 'check_order_status', 'description' => 'Look up the status and tracking information for a customer order. Use this when a customer asks about their order status, shipping, or delivery.', 'parameters' => [ 'type' => 'object', 'properties' => [ 'order_id' => [ 'type' => 'string', 'description' => 'The order ID (e.g., ORD-12345)', ], ], 'required' => ['order_id'], ], 'webhookUrl' => 'https://api.yourcompany.com/webhooks/order-status', 'webhookSecret' => 'whsec_your_secret_here', // Optional ]); echo "Tool created: {$tool->id}\n"; ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/senders/sender_abc123/agent/tools \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "check_order_status", "description": "Look up the status and tracking information for a customer order.", "parameters": { "type": "object", "properties": { "order_id": { "type": "string", "description": "The order ID (e.g., ORD-12345)" } }, "required": ["order_id"] }, "webhookUrl": "https://api.yourcompany.com/webhooks/order-status", "webhookSecret": "whsec_your_secret_here" }' ``` ### List Tools ```typescript TypeScript theme={null} const tools = await zavu.senders.agent.tools.list("sender_abc123"); for (const tool of tools.items) { console.log(`${tool.name} - ${tool.enabled ? "Enabled" : "Disabled"}`); } ``` ```python Python theme={null} tools = zavu.senders.agent.tools.list("sender_abc123") for tool in tools.items: status = "Enabled" if tool.enabled else "Disabled" print(f"{tool.name} - {status}") ``` ```ruby Ruby theme={null} tools = client.senders.agent.tools.list("sender_abc123") tools.items.each do |tool| status = tool.enabled ? "Enabled" : "Disabled" puts "#{tool.name} - #{status}" end ``` ```go Go theme={null} tools, _ := client.Senders.Agent.Tools.List(context.TODO(), "sender_abc123", zavudev.ToolListParams{}) for _, tool := range tools.Items { status := "Disabled" if tool.Enabled { status = "Enabled" } fmt.Printf("%s - %s\n", tool.Name, status) } ``` ```php PHP theme={null} $tools = $client->senders->agent->tools->list('sender_abc123'); foreach ($tools->items as $tool) { $status = $tool->enabled ? 'Enabled' : 'Disabled'; echo "{$tool->name} - {$status}\n"; } ``` ```bash cURL theme={null} curl https://api.zavu.dev/v1/senders/sender_abc123/agent/tools \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` ### Update Tool ```typescript TypeScript theme={null} const tool = await zavu.senders.agent.tools.update( "sender_abc123", "tool_xyz789", { description: "Updated description for better AI understanding", enabled: true, } ); ``` ```python Python theme={null} tool = zavu.senders.agent.tools.update( "sender_abc123", "tool_xyz789", description="Updated description for better AI understanding", enabled=True, ) ``` ```ruby Ruby theme={null} tool = client.senders.agent.tools.update("sender_abc123", "tool_xyz789", description: "Updated description for better AI understanding", enabled: true ) ``` ```go Go theme={null} tool, _ := client.Senders.Agent.Tools.Update(context.TODO(), "sender_abc123", "tool_xyz789", zavudev.ToolUpdateParams{ Description: zavudev.String("Updated description for better AI understanding"), Enabled: zavudev.Bool(true), }) ``` ```php PHP theme={null} $tool = $client->senders->agent->tools->update('sender_abc123', 'tool_xyz789', [ 'description' => 'Updated description for better AI understanding', 'enabled' => true, ]); ``` ```bash cURL theme={null} curl -X PATCH https://api.zavu.dev/v1/senders/sender_abc123/agent/tools/tool_xyz789 \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{"description": "Updated description", "enabled": true}' ``` ### Delete Tool ```typescript TypeScript theme={null} await zavu.senders.agent.tools.delete("sender_abc123", "tool_xyz789"); ``` ```python Python theme={null} zavu.senders.agent.tools.delete("sender_abc123", "tool_xyz789") ``` ```ruby Ruby theme={null} client.senders.agent.tools.delete("sender_abc123", "tool_xyz789") ``` ```go Go theme={null} client.Senders.Agent.Tools.Delete(context.TODO(), "sender_abc123", "tool_xyz789") ``` ```php PHP theme={null} $client->senders->agent->tools->delete('sender_abc123', 'tool_xyz789'); ``` ```bash cURL theme={null} curl -X DELETE https://api.zavu.dev/v1/senders/sender_abc123/agent/tools/tool_xyz789 \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` ## Webhook Payload When the agent invokes a tool, your webhook receives a POST request: ```json theme={null} { "tool": "check_order_status", "arguments": { "order_id": "ORD-12345" }, "context": { "messageId": "msg_abc123", "contactPhone": "+14155551234", "sessionId": "session_xyz789" }, "timestamp": 1703001234567 } ``` ### Headers | Header | Description | | - | - | | `Content-Type` | `application/json` | | `X-Zavu-Tool` | Tool name being invoked | | `X-Zavu-Timestamp` | Unix timestamp of the request | | `X-Zavu-Signature` | HMAC-SHA256 signature (if webhook secret is configured) | ## Webhook Response Your webhook should return JSON with the result: ```json theme={null} { "status": "shipped", "carrier": "FedEx", "trackingNumber": "7891234567890", "estimatedDelivery": "2024-01-15" } ``` The agent will incorporate this data into its response to the user. ### Error Responses Return appropriate HTTP status codes for errors: ```json theme={null} { "error": "Order not found", "code": "ORDER_NOT_FOUND" } ``` The agent will handle errors gracefully and inform the user appropriately. ## Signature Verification If you configured a webhook secret, verify the signature to ensure requests are from Zavu: ```typescript TypeScript theme={null} import crypto from "crypto"; function verifySignature( payload: string, signature: string, secret: string ): boolean { const expected = crypto .createHmac("sha256", secret) .update(payload) .digest("hex"); return crypto.timingSafeEqual( Buffer.from(signature), Buffer.from(expected) ); } // In your webhook handler app.post("/webhooks/order-status", (req, res) => { const signature = req.headers["x-zavu-signature"]; const payload = JSON.stringify(req.body); if (!verifySignature(payload, signature, process.env.WEBHOOK_SECRET)) { return res.status(401).json({ error: "Invalid signature" }); } // Process the request... }); ``` ```python Python theme={null} import hmac import hashlib def verify_signature(payload: str, signature: str, secret: str) -> bool: expected = hmac.new( secret.encode(), payload.encode(), hashlib.sha256 ).hexdigest() return hmac.compare_digest(signature, expected) # In your webhook handler (Flask example) @app.route("/webhooks/order-status", methods=["POST"]) def handle_order_status(): signature = request.headers.get("X-Zavu-Signature") payload = request.get_data(as_text=True) if not verify_signature(payload, signature, WEBHOOK_SECRET): return {"error": "Invalid signature"}, 401 # Process the request... ``` ```ruby Ruby theme={null} require "openssl" def verify_signature(payload, signature, secret) expected = OpenSSL::HMAC.hexdigest("SHA256", secret, payload) Rack::Utils.secure_compare(signature, expected) end # In your webhook handler (Sinatra example) post "/webhooks/order-status" do signature = request.env["HTTP_X_ZAVU_SIGNATURE"] payload = request.body.read unless verify_signature(payload, signature, ENV["WEBHOOK_SECRET"]) halt 401, { error: "Invalid signature" }.to_json end # Process the request... end ``` ```go Go theme={null} import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "io" "net/http" ) func verifySignature(payload, signature, secret string) bool { mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(payload)) expected := hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(signature), []byte(expected)) } // In your webhook handler http.HandleFunc("/webhooks/order-status", func(w http.ResponseWriter, r *http.Request) { signature := r.Header.Get("X-Zavu-Signature") body, _ := io.ReadAll(r.Body) if !verifySignature(string(body), signature, os.Getenv("WEBHOOK_SECRET")) { http.Error(w, `{"error":"Invalid signature"}`, http.StatusUnauthorized) return } // Process the request... }) ``` ```php PHP theme={null} function verifySignature(string $payload, string $signature, string $secret): bool { $expected = hash_hmac('sha256', $payload, $secret); return hash_equals($expected, $signature); } // In your webhook handler $signature = $_SERVER['HTTP_X_ZAVU_SIGNATURE'] ?? ''; $payload = file_get_contents('php://input'); if (!verifySignature($payload, $signature, getenv('WEBHOOK_SECRET'))) { http_response_code(401); echo json_encode(['error' => 'Invalid signature']); exit; } // Process the request... ``` ## Tool Execution Flow ``` Customer: "Where is my order #12345?" | v +---------------+ | AI Agent | | Analyzes msg | +---------------+ | v +---------------+ | Decides to | | use tool | +---------------+ | v +---------------+ | Calls webhook | | with order_id | +---------------+ | v +---------------+ | Your Server | | Returns data | +---------------+ | v +---------------+ | AI formulates | | response | +---------------+ | v Customer: "Your order #12345 shipped via FedEx and will arrive January 15th." ``` ## Best Practices > [card] Write Clear Descriptions > > The AI uses the description to decide when to use the tool. Be specific about when it should and shouldn't be used. > [card] Use HTTPS > > Webhook URLs must use HTTPS for security. We reject HTTP endpoints. > [card] Handle Errors Gracefully > > Return meaningful error messages so the agent can inform the user appropriately. > [card] Respond Quickly > > Keep webhook response times under 10 seconds. The user is waiting for a response. ### Example Tool Descriptions **Good description:** ```text theme={null} Look up the status and tracking information for a customer order. Use when the customer asks about their order status, shipping progress, delivery estimate, or tracking number. Requires the order ID which typically starts with "ORD-" followed by numbers. ``` **Poor description:** ```text theme={null} Get order info. ``` > [!WARNING] > > Poor descriptions lead to tools being used incorrectly or not at all. Invest time in writing clear, detailed descriptions. ## Security Considerations 1. **Always use HTTPS** for webhook endpoints 2. **Verify signatures** using the webhook secret 3. **Validate parameters** before processing 4. **Rate limit** your webhook endpoints 5. **Log requests** for debugging and auditing 6. **Never expose** sensitive data in tool responses that shouldn't be shared with customers ## Next Steps > [card] [Add Knowledge Base](/guides/ai-agents/knowledge-base) > > Let your agent answer questions from documents > [card] [Create Flows](/guides/ai-agents/flows) > > Build structured conversation paths that use tools # Adding Contacts Source: https://docs.zavu.dev/guides/broadcasts/adding-contacts Add recipients to your broadcast in batches Add contacts to your broadcast before sending. Contacts can include personalization variables for customized messages. ## Adding Contacts ```typescript TypeScript theme={null} const result = await zavu.broadcasts.contacts.add(broadcast.id, { contacts: [ { recipient: "+14155551234" }, { recipient: "+14155555678" }, { recipient: "+14155559012" }, ], }); console.log(`Added: ${result.added}`); console.log(`Duplicates: ${result.duplicates}`); console.log(`Invalid: ${result.invalid}`); ``` ```python Python theme={null} result = zavu.broadcasts.contacts.add(broadcast.id, contacts=[ {"recipient": "+14155551234"}, {"recipient": "+14155555678"}, {"recipient": "+14155559012"}, ]) print(f"Added: {result.added}") print(f"Duplicates: {result.duplicates}") print(f"Invalid: {result.invalid}") ``` ```ruby Ruby theme={null} result = client.broadcasts.contacts.add(broadcast.id, contacts: [ { recipient: "+14155551234" }, { recipient: "+14155555678" }, { recipient: "+14155559012" }, ]) puts "Added: #{result.added}" puts "Duplicates: #{result.duplicates}" puts "Invalid: #{result.invalid}" ``` ```go Go theme={null} result, _ := client.Broadcasts.Contacts.Add(context.TODO(), broadcast.ID, zavudev.BroadcastAddContactsParams{ Contacts: []zavudev.BroadcastContactInput{ {Recipient: zavudev.String("+14155551234")}, {Recipient: zavudev.String("+14155555678")}, {Recipient: zavudev.String("+14155559012")}, }, }) fmt.Printf("Added: %d\n", result.Added) fmt.Printf("Duplicates: %d\n", result.Duplicates) fmt.Printf("Invalid: %d\n", result.Invalid) ``` ```php PHP theme={null} $result = $client->broadcasts->contacts->add($broadcast->id, [ 'contacts' => [ ['recipient' => '+14155551234'], ['recipient' => '+14155555678'], ['recipient' => '+14155559012'], ], ]); echo "Added: {$result->added}\n"; echo "Duplicates: {$result->duplicates}\n"; echo "Invalid: {$result->invalid}\n"; ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/broadcasts/{broadcastId}/contacts \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "contacts": [ {"recipient": "+14155551234"}, {"recipient": "+14155555678"}, {"recipient": "+14155559012"} ] }' ``` ### Response ```json theme={null} { "added": 3, "duplicates": 0, "invalid": 0, "errors": [] } ``` ## With Personalization Add template variables for each contact to personalize messages: ```typescript theme={null} await zavu.broadcasts.contacts.add(broadcast.id, { contacts: [ { recipient: "+14155551234", templateVariables: { name: "John Smith", order_id: "ORD-12345", delivery_date: "January 20th", }, }, { recipient: "+14155555678", templateVariables: { name: "Jane Doe", order_id: "ORD-12346", delivery_date: "January 21st", }, }, ], }); ``` The message template `Hi {{name}}, your order #{{order_id}} arrives {{delivery_date}}!` becomes: * John: "Hi John Smith, your order #ORD-12345 arrives January 20th!" * Jane: "Hi Jane Doe, your order #ORD-12346 arrives January 21st!" ### Per-contact URL button variables If the template has a dynamic URL button (e.g., `https://example.com/orders/{{1}}`), supply each contact's value via `templateButtonVariables`. Keys are the button index in the template's `buttons` array (`"0"`, `"1"`, `"2"`). ```typescript theme={null} await zavu.broadcasts.contacts.add(broadcast.id, { contacts: [ { recipient: "+14155551234", templateVariables: { "1": "John Smith" }, templateButtonVariables: { "0": "ORD-12345" }, }, { recipient: "+14155555678", templateVariables: { "1": "Jane Doe" }, templateButtonVariables: { "0": "ORD-12346" }, }, ], }); ``` When a contact omits `templateButtonVariables`, the broadcast falls back to the defaults defined on the broadcast itself. ## Batch Processing Add contacts in batches of up to 1,000 per request. For larger lists, make multiple requests: ```typescript theme={null} const BATCH_SIZE = 1000; async function addAllContacts(broadcastId: string, allContacts: Contact[]) { let totalAdded = 0; for (let i = 0; i < allContacts.length; i += BATCH_SIZE) { const batch = allContacts.slice(i, i + BATCH_SIZE); const result = await zavu.broadcasts.contacts.add(broadcastId, { contacts: batch.map(c => ({ recipient: c.phone, templateVariables: { name: c.name }, })), }); totalAdded += result.added; console.log(`Batch ${Math.floor(i / BATCH_SIZE) + 1}: Added ${result.added} contacts`); } return totalAdded; } ``` ## Recipient Formats | Channel | Format | Example | | - | - | - | | SMS | E.164 phone | `+14155551234` | | WhatsApp | E.164 phone | `+14155551234` | | Email | Email address | `user@example.com` | > [!WARNING] > > Phone numbers must be in E.164 format (starting with `+` and country code). Invalid formats will be rejected. ## Handling Duplicates The API automatically deduplicates contacts: * **Within request**: If the same recipient appears twice in one request, only one is added * **Across requests**: If a recipient is already in the broadcast, they won't be added again ```json theme={null} { "added": 8, "duplicates": 2, "invalid": 0, "errors": [] } ``` ## Handling Invalid Contacts Invalid contacts are rejected with detailed errors: ```json theme={null} { "added": 7, "duplicates": 0, "invalid": 3, "errors": [ { "recipient": "invalid-phone", "reason": "Invalid phone number format" }, { "recipient": "+1234", "reason": "Phone number too short" }, { "recipient": "not-an-email", "reason": "Invalid email format" } ] } ``` > [!TIP] > > Validate phone numbers before adding them to avoid high rejection rates. Use E.164 format: `+[country code][number]` with no spaces or dashes. ## Listing Contacts View contacts in a broadcast: ```typescript TypeScript theme={null} const contacts = await zavu.broadcasts.contacts.list(broadcast.id, { limit: 50, }); for (const contact of contacts.items) { console.log(`${contact.recipient}: ${contact.status}`); } // Paginate through all contacts if (contacts.nextCursor) { const nextPage = await zavu.broadcasts.contacts.list(broadcast.id, { cursor: contacts.nextCursor, }); } ``` ```python Python theme={null} contacts = zavu.broadcasts.contacts.list(broadcast.id, limit=50) for contact in contacts.items: print(f"{contact.recipient}: {contact.status}") # Paginate through all contacts if contacts.next_cursor: next_page = zavu.broadcasts.contacts.list( broadcast.id, cursor=contacts.next_cursor ) ``` ```ruby Ruby theme={null} contacts = client.broadcasts.contacts.list(broadcast.id, limit: 50) contacts.items.each do |contact| puts "#{contact.recipient}: #{contact.status}" end # Paginate through all contacts if contacts.next_cursor next_page = client.broadcasts.contacts.list( broadcast.id, cursor: contacts.next_cursor ) end ``` ```go Go theme={null} contacts, _ := client.Broadcasts.Contacts.List(context.TODO(), broadcast.ID, zavudev.BroadcastListContactsParams{ Limit: zavudev.Int(50), }) for _, contact := range contacts.Items { fmt.Printf("%s: %s\n", contact.Recipient, contact.Status) } // Paginate through all contacts if contacts.NextCursor != nil { nextPage, _ := client.Broadcasts.Contacts.List(context.TODO(), broadcast.ID, zavudev.BroadcastListContactsParams{ Cursor: contacts.NextCursor, }) _ = nextPage } ``` ```php PHP theme={null} $contacts = $client->broadcasts->contacts->list($broadcast->id, [ 'limit' => 50, ]); foreach ($contacts->items as $contact) { echo "{$contact->recipient}: {$contact->status}\n"; } // Paginate through all contacts if ($contacts->nextCursor) { $nextPage = $client->broadcasts->contacts->list($broadcast->id, [ 'cursor' => $contacts->nextCursor, ]); } ``` ```bash cURL theme={null} curl "https://api.zavu.dev/v1/broadcasts/{broadcastId}/contacts?limit=50" \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` ### Response ```json theme={null} { "items": [ { "id": "bc_abc123", "recipient": "+14155551234", "recipientType": "phone", "status": "pending", "templateVariables": { "name": "John" }, "createdAt": "2024-01-15T10:30:00.000Z" }, { "id": "bc_def456", "recipient": "+14155555678", "recipientType": "phone", "status": "pending", "templateVariables": { "name": "Jane" }, "createdAt": "2024-01-15T10:30:00.000Z" } ], "nextCursor": "eyJpZCI6ImJjX2RlZjQ1NiJ9" } ``` ## Filtering by Status Filter contacts by delivery status: ```bash theme={null} curl "https://api.zavu.dev/v1/broadcasts/{broadcastId}/contacts?status=failed" \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` | Status | Description | | - | - | | `pending` | Not yet sent | | `queued` | Message created, waiting to send | | `sending` | Currently being sent | | `delivered` | Successfully delivered | | `failed` | Delivery failed | | `skipped` | Skipped (broadcast cancelled) | ## Removing Contacts Remove a contact before the broadcast is sent: ```typescript theme={null} // The broadcast contact id comes first; the broadcast it belongs to is a param. await zavu.broadcasts.contacts.remove(contact.id, { broadcastId: broadcast.id }); ``` > [!WARNING] > > Contacts can only be removed while the broadcast is in `draft` status. Once sending begins, contacts cannot be removed. ## Limits | Limit | Value | Notes | | - | - | - | | Contacts per request | 1,000 | Make multiple requests for larger lists | | Contacts per broadcast | 1,000 (default) | Contact support to increase this limit | | Template variable size | 1,024 chars | Per variable | ## Next Steps > [card] [Sending & Scheduling](/guides/broadcasts/sending) > > Send your broadcast or schedule it for later # Creating Broadcasts Source: https://docs.zavu.dev/guides/broadcasts/creating-broadcasts Configure your broadcast message and channel settings Create a broadcast to define your message content and delivery channel. Broadcasts start in `draft` status, allowing you to add contacts before sending. ## Basic Broadcast ```typescript TypeScript theme={null} const broadcast = await zavu.broadcasts.create({ name: "Weekly Newsletter", channel: "sms", text: "This week's top deals are live! Shop now at example.com/deals", }); ``` ```python Python theme={null} broadcast = zavu.broadcasts.create( name="Weekly Newsletter", channel="sms", text="This week's top deals are live! Shop now at example.com/deals" ) ``` ```ruby Ruby theme={null} broadcast = client.broadcasts.create( name: "Weekly Newsletter", channel: "sms", text: "This week's top deals are live! Shop now at example.com/deals" ) ``` ```go Go theme={null} broadcast, _ := client.Broadcasts.New(context.TODO(), zavudev.BroadcastCreateParams{ Name: zavudev.String("Weekly Newsletter"), Channel: zavudev.String("sms"), Text: zavudev.String("This week's top deals are live! Shop now at example.com/deals"), }) ``` ```php PHP theme={null} $broadcast = $client->broadcasts->create([ 'name' => 'Weekly Newsletter', 'channel' => 'sms', 'text' => "This week's top deals are live! Shop now at example.com/deals", ]); ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/broadcasts \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Weekly Newsletter", "channel": "sms", "text": "This week'"'"'s top deals are live! Shop now at example.com/deals" }' ``` ### Response ```json theme={null} { "id": "brd_abc123", "name": "Weekly Newsletter", "status": "draft", "channel": "sms", "messageType": "text", "text": "This week's top deals are live! Shop now at example.com/deals", "totalContacts": 0, "pendingCount": 0, "sendingCount": 0, "deliveredCount": 0, "failedCount": 0, "createdAt": "2024-01-15T10:30:00.000Z" } ``` ## Parameters | Parameter | Type | Required | Description | | - | - | - | - | | `name` | string | Yes | Name for identifying this broadcast | | `channel` | string | Yes | `sms`, `whatsapp`, or `email` | | `messageType` | string | No | Message type (default: `text`) | | `text` | string | Conditional | Message body (required for text messages) | | `content` | object | Conditional | Media/template content for rich messages | | `subject` | string | Conditional | Email subject (required for email channel) | | `htmlBody` | string | No | HTML body for email messages | | `scheduledAt` | string | No | ISO 8601 datetime for scheduled delivery | | `metadata` | object | No | Custom key-value pairs | | `idempotencyKey` | string | No | Unique key to prevent duplicates | ## Channel-Specific Examples ### SMS Broadcast ```typescript theme={null} const broadcast = await zavu.broadcasts.create({ name: "Flash Sale Alert", channel: "sms", text: "FLASH SALE: 50% off everything for the next 2 hours! Use code FLASH50. Reply STOP to opt out.", }); ``` ### WhatsApp Text Broadcast ```typescript theme={null} const broadcast = await zavu.broadcasts.create({ name: "Order Update", channel: "whatsapp", text: "Hi {{name}}! Your order #{{order_id}} is out for delivery. Track it here: {{tracking_url}}", }); ``` ### WhatsApp Template Broadcast For marketing messages to recipients outside the 24-hour window, use approved templates: ```typescript theme={null} const broadcast = await zavu.broadcasts.create({ name: "Holiday Promo", channel: "whatsapp", messageType: "template", content: { templateId: "tmpl_holiday_sale", templateVariables: { "1": "Holiday", // Broadcast-level defaults "2": "20%", }, }, }); ``` > [!NOTE] > > Per-contact template variables can override broadcast-level defaults when adding contacts. ### WhatsApp Media Broadcast ```typescript theme={null} const broadcast = await zavu.broadcasts.create({ name: "Product Launch", channel: "whatsapp", messageType: "image", text: "Introducing our newest product! Available now.", content: { mediaUrl: "https://example.com/product-image.jpg", }, }); ``` ### Email Broadcast ```typescript theme={null} const broadcast = await zavu.broadcasts.create({ name: "Monthly Newsletter", channel: "email", subject: "Your {{month}} Newsletter from {{company}}", text: "Hi {{name}}, here are this month's highlights...", htmlBody: `

Hello {{name}}!

Here are this month's highlights...

`, }); ``` ## Using Template Variables Template variables use `{{variable_name}}` syntax. Variables can be set at two levels: 1. **Broadcast level**: Default values for all contacts 2. **Contact level**: Override defaults for specific contacts ```typescript theme={null} // Broadcast with default variables const broadcast = await zavu.broadcasts.create({ name: "Welcome Campaign", channel: "sms", text: "Welcome to {{company}}, {{name}}! Use code {{promo_code}} for 10% off.", }); // Contact-level overrides when adding contacts await zavu.broadcasts.contacts.add(broadcast.id, { contacts: [ { recipient: "+14155551234", templateVariables: { name: "John", company: "Acme Inc", promo_code: "JOHN10", }, }, { recipient: "+14155555678", templateVariables: { name: "Jane", company: "Acme Inc", promo_code: "JANE10", }, }, ], }); ``` ## Using a Specific Sender By default, broadcasts use your project's default sender. Specify a different sender: ```typescript TypeScript theme={null} const broadcast = await zavu.broadcasts.create({ name: "Support Alert", channel: "sms", text: "System maintenance scheduled for tonight.", senderId: "snd_support123", }); ``` ```python Python theme={null} broadcast = zavu.broadcasts.create( name="Support Alert", channel="sms", text="System maintenance scheduled for tonight.", sender_id="snd_support123" ) ``` ```ruby Ruby theme={null} broadcast = client.broadcasts.create( name: "Support Alert", channel: "sms", text: "System maintenance scheduled for tonight.", sender_id: "snd_support123" ) ``` ```go Go theme={null} broadcast, _ := client.Broadcasts.New(context.TODO(), zavudev.BroadcastCreateParams{ Name: zavudev.String("Support Alert"), Channel: zavudev.String("sms"), Text: zavudev.String("System maintenance scheduled for tonight."), SenderID: zavudev.String("snd_support123"), }) ``` ```php PHP theme={null} $broadcast = $client->broadcasts->create([ 'name' => 'Support Alert', 'channel' => 'sms', 'text' => 'System maintenance scheduled for tonight.', 'senderId' => 'snd_support123', ]); ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/broadcasts \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Support Alert", "channel": "sms", "text": "System maintenance scheduled for tonight.", "senderId": "snd_support123" }' ``` ## Updating a Broadcast Update a broadcast while it's still in `draft` status: ```typescript theme={null} await zavu.broadcasts.update(broadcast.id, { name: "Updated Campaign Name", text: "Updated message content", }); ``` > [!WARNING] > > Broadcasts can only be updated while in `draft` status. Once sending begins, the message content is locked. ## Deleting a Broadcast Delete a draft broadcast: ```typescript theme={null} await zavu.broadcasts.delete(broadcast.id); ``` > [!WARNING] > > Only `draft` broadcasts can be deleted. Use the cancel endpoint for broadcasts that have started sending. ## Idempotency Prevent duplicate broadcasts with an idempotency key: ```typescript theme={null} const broadcast = await zavu.broadcasts.create({ name: "Daily Reminder", channel: "sms", text: "Don't forget your daily check-in!", idempotencyKey: "daily-reminder-2024-01-15", }); ``` If you make another request with the same `idempotencyKey`, the API returns the existing broadcast instead of creating a duplicate. ## Next Steps > [card] [Adding Contacts](/guides/broadcasts/adding-contacts) > > Learn how to add recipients to your broadcast # Broadcasts Source: https://docs.zavu.dev/guides/broadcasts/overview Send messages to thousands of recipients with a single API call Broadcasts let you send the same message to many recipients without making individual API calls. Perfect for marketing campaigns, announcements, and bulk notifications. ## Why Use Broadcasts? Instead of making 10,000 individual API calls: ```typescript theme={null} // Without broadcasts - 10,000 API calls for (const contact of contacts) { await zavu.messages.send({ to: contact.phone, text: "Black Friday sale starts now!", }); } ``` Use a single broadcast: ```typescript theme={null} // With broadcasts - 3 API calls total const broadcast = await zavu.broadcasts.create({ name: "Black Friday Campaign", channel: "sms", text: "Black Friday sale starts now!", }); await zavu.broadcasts.contacts.add(broadcast.id, { contacts: contacts.map(c => ({ recipient: c.phone })), }); await zavu.broadcasts.send(broadcast.id); ``` ## Key Features > [card] Batch Processing > > Add up to 1,000 contacts per request. Zavu handles rate limiting and delivery optimization automatically. > [card] Personalization > > Customize each message with per-contact template variables like names, order IDs, or custom fields. > [card] Scheduling > > Schedule broadcasts for future delivery. Perfect for time-zone aware campaigns. > [card] Real-time Progress > > Poll for delivery progress with instant updates on sent, delivered, and failed counts. ## Balance Reservation When you send a broadcast, Zavu reserves the estimated cost from your balance. This ensures funds are available for the entire campaign and prevents overspending. ``` Balance: $100.00 → Send Broadcast ($25 estimated) → Available: $75.00, Reserved: $25.00 ``` Reserved funds are released when the broadcast completes or is cancelled. [Learn more](/guides/broadcasts/sending#balance-reservation) ## Broadcast Lifecycle ``` Draft → Pending Review → Approved → Scheduled (optional) → Sending → Completed ↓ ↓ Rejected ────→ Escalated ────→ Rejected Final Cancelled ↑ ↓ └──── Edit ←───┘ ``` | Status | Description | | - | - | | `draft` | Initial state. Add/remove contacts freely. | | `pending_review` | Content being reviewed by AI. | | `pending_admin_review` | Passed automated review, waiting for a person at Zavu. Every channel except email (and WhatsApp on an approved template, which skips review) stops here. | | `approved` | Review passed, ready to send. | | `rejected` | Content rejected, edit and retry. | | `escalated` | Sent to human review. | | `rejected_final` | Rejected by human review (cannot appeal). | | `scheduled` | Waiting to send at scheduled time. | | `sending` | Messages being delivered. | | `completed` | All messages processed. | | `cancelled` | Broadcast stopped before completion. Can be cancelled from any status up to and including `sending`, except `paused` and `rejected_final`. | ## Quick Example ```typescript TypeScript theme={null} import Zavudev from '@zavudev/sdk'; const zavu = new Zavudev({ apiKey: process.env['ZAVUDEV_API_KEY'], // This is the default and can be omitted }); // 1. Create the broadcast const broadcast = await zavu.broadcasts.create({ name: "Order Confirmation Campaign", channel: "sms", text: "Hi {{name}}, your order #{{order_id}} has shipped!", }); // 2. Add contacts with personalization await zavu.broadcasts.contacts.add(broadcast.id, { contacts: [ { recipient: "+14155551234", templateVariables: { name: "John", order_id: "ORD-001" } }, { recipient: "+14155555678", templateVariables: { name: "Jane", order_id: "ORD-002" } }, ], }); // 3. Send immediately await zavu.broadcasts.send(broadcast.id); // 4. Check progress const progress = await zavu.broadcasts.progress(broadcast.id); console.log(`${progress.percentComplete}% complete`); ``` ```python Python theme={null} import os from zavudev import Zavudev zavu = Zavudev( api_key=os.environ.get("ZAVUDEV_API_KEY"), # This is the default and can be omitted ) # 1. Create the broadcast broadcast = zavu.broadcasts.create( name="Order Confirmation Campaign", channel="sms", text="Hi {{name}}, your order #{{order_id}} has shipped!" ) # 2. Add contacts with personalization zavu.broadcasts.contacts.add(broadcast.id, contacts=[ { "recipient": "+14155551234", "templateVariables": {"name": "John", "order_id": "ORD-001"} }, { "recipient": "+14155555678", "templateVariables": {"name": "Jane", "order_id": "ORD-002"} }, ]) # 3. Send immediately zavu.broadcasts.send(broadcast.id) # 4. Check progress progress = zavu.broadcasts.progress(broadcast.id) print(f"{progress.percent_complete}% complete") ``` ```ruby Ruby theme={null} require "zavudev" client = Zavudev::Client.new(api_key: ENV["ZAVUDEV_API_KEY"]) # 1. Create the broadcast broadcast = client.broadcasts.create( name: "Order Confirmation Campaign", channel: "sms", text: "Hi {{name}}, your order #{{order_id}} has shipped!" ) # 2. Add contacts with personalization client.broadcasts.contacts.add(broadcast.id, contacts: [ { recipient: "+14155551234", template_variables: { name: "John", order_id: "ORD-001" } }, { recipient: "+14155555678", template_variables: { name: "Jane", order_id: "ORD-002" } }, ]) # 3. Send immediately client.broadcasts.send(broadcast.id) # 4. Check progress progress = client.broadcasts.progress(broadcast.id) puts "#{progress.percent_complete}% complete" ``` ```go Go theme={null} package main import ( "context" "fmt" "os" "github.com/zavudev/sdk-go" ) func main() { client := zavudev.NewClient(zavudev.WithAPIKey(os.Getenv("ZAVUDEV_API_KEY"))) // 1. Create the broadcast broadcast, _ := client.Broadcasts.New(context.TODO(), zavudev.BroadcastCreateParams{ Name: zavudev.String("Order Confirmation Campaign"), Channel: zavudev.String("sms"), Text: zavudev.String("Hi {{name}}, your order #{{order_id}} has shipped!"), }) // 2. Add contacts with personalization client.Broadcasts.Contacts.Add(context.TODO(), broadcast.ID, zavudev.BroadcastAddContactsParams{ Contacts: []zavudev.BroadcastContactInput{ { Recipient: zavudev.String("+14155551234"), TemplateVariables: map[string]string{"name": "John", "order_id": "ORD-001"}, }, { Recipient: zavudev.String("+14155555678"), TemplateVariables: map[string]string{"name": "Jane", "order_id": "ORD-002"}, }, }, }) // 3. Send immediately client.Broadcasts.Send(context.TODO(), broadcast.ID, zavudev.BroadcastSendParams{}) // 4. Check progress progress, _ := client.Broadcasts.Progress(context.TODO(), broadcast.ID) fmt.Printf("%v%% complete\n", progress.PercentComplete) } ``` ```php PHP theme={null} $client = new Zavudev\Client(apiKey: getenv('ZAVUDEV_API_KEY')); // 1. Create the broadcast $broadcast = $client->broadcasts->create([ 'name' => 'Order Confirmation Campaign', 'channel' => 'sms', 'text' => 'Hi {{name}}, your order #{{order_id}} has shipped!', ]); // 2. Add contacts with personalization $client->broadcasts->contacts->add($broadcast->id, [ 'contacts' => [ [ 'recipient' => '+14155551234', 'templateVariables' => ['name' => 'John', 'order_id' => 'ORD-001'], ], [ 'recipient' => '+14155555678', 'templateVariables' => ['name' => 'Jane', 'order_id' => 'ORD-002'], ], ], ]); // 3. Send immediately $client->broadcasts->send($broadcast->id); // 4. Check progress $progress = $client->broadcasts->progress($broadcast->id); echo "{$progress->percentComplete}% complete\n"; ``` ```bash cURL theme={null} # 1. Create the broadcast curl -X POST https://api.zavu.dev/v1/broadcasts \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Order Confirmation Campaign", "channel": "sms", "text": "Hi {{name}}, your order #{{order_id}} has shipped!" }' # 2. Add contacts curl -X POST https://api.zavu.dev/v1/broadcasts/{broadcastId}/contacts \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "contacts": [ {"recipient": "+14155551234", "templateVariables": {"name": "John", "order_id": "ORD-001"}}, {"recipient": "+14155555678", "templateVariables": {"name": "Jane", "order_id": "ORD-002"}} ] }' # 3. Send the broadcast curl -X POST https://api.zavu.dev/v1/broadcasts/{broadcastId}/send \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" # 4. Check progress curl https://api.zavu.dev/v1/broadcasts/{broadcastId}/progress \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` ## Supported Channels | Channel | Message Types | Personalization | | - | - | - | | SMS | Text | Template variables | | WhatsApp | Text, Image, Video, Audio, Document, Template | Template variables | | Email | Text, HTML | Template variables | > [!NOTE] > > WhatsApp broadcasts using free-form messages (non-template) require an open 24-hour conversation window with each recipient. Use WhatsApp templates for marketing campaigns to recipients who haven't messaged you recently. ## Limits | Limit | Default | Notes | | - | - | - | | Contacts per request | 1,000 | Add contacts in batches | | Contacts per broadcast | 1,000 | Contact support to increase | | Concurrent broadcasts | No limit | Rate limits still apply | ## Next Steps > [card] [Creating Broadcasts](/guides/broadcasts/creating-broadcasts) > > Configure message content and channel settings > [card] [Adding Contacts](/guides/broadcasts/adding-contacts) > > Batch upload and personalization > [card] [Sending & Scheduling](/guides/broadcasts/sending) > > Send immediately or schedule for later > [card] [Tracking Progress](/guides/broadcasts/tracking-progress) > > Monitor delivery in real-time ## API Reference Manage broadcasts programmatically: * [List broadcasts](/api-reference/list-broadcasts) — fetch all broadcast campaigns * [Get a broadcast](/api-reference/get-broadcast) — retrieve broadcast details and status * [Get broadcast progress](/api-reference/get-broadcast-progress) — real-time delivery metrics # Sending & Scheduling Source: https://docs.zavu.dev/guides/broadcasts/sending Send broadcasts immediately or schedule for later delivery Once you've added contacts to your broadcast, trigger the send to start delivering messages. ## Send Immediately ```typescript TypeScript theme={null} const broadcast = await zavu.broadcasts.send(broadcastId); console.log(`Status: ${broadcast.status}`); // "pending_review" ``` ```python Python theme={null} broadcast = zavu.broadcasts.send(broadcast_id) print(f"Status: {broadcast.status}") # "pending_review" ``` ```ruby Ruby theme={null} broadcast = client.broadcasts.send(broadcast_id) puts "Status: #{broadcast.status}" # "pending_review" ``` ```go Go theme={null} broadcast, _ := client.Broadcasts.Send(context.TODO(), broadcastID, zavudev.BroadcastSendParams{}) fmt.Printf("Status: %s\n", broadcast.Status) // "pending_review" ``` ```php PHP theme={null} $broadcast = $client->broadcasts->send($broadcastId); echo "Status: {$broadcast->status}\n"; // "pending_review" ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/broadcasts/{broadcastId}/send \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` ### Response ```json theme={null} { "id": "brd_abc123", "name": "Weekly Newsletter", "status": "pending_review", "channel": "sms", "totalContacts": 1500, "reviewAttempts": 1, "createdAt": "2024-01-15T10:00:00.000Z" } ``` > [!NOTE] > > All broadcasts go through an automated AI content review before sending. The broadcast enters `pending_review` status and, once approved, proceeds to `sending` automatically. Subscribe to the `broadcast.status_changed` webhook event to track the review process in real-time. ## Schedule for Later Schedule a broadcast to send at a specific time: ```typescript TypeScript theme={null} // Schedule for tomorrow at 9am UTC const broadcast = await zavu.broadcasts.send(broadcastId, { scheduledAt: "2024-01-16T09:00:00.000Z", }); console.log(`Status: ${broadcast.status}`); // "scheduled" console.log(`Scheduled for: ${broadcast.scheduledAt}`); ``` ```python Python theme={null} broadcast = zavu.broadcasts.send( broadcast_id, scheduled_at="2024-01-16T09:00:00.000Z" ) print(f"Status: {broadcast.status}") # "scheduled" print(f"Scheduled for: {broadcast.scheduled_at}") ``` ```ruby Ruby theme={null} broadcast = client.broadcasts.send(broadcast_id, scheduled_at: "2024-01-16T09:00:00.000Z" ) puts "Status: #{broadcast.status}" # "scheduled" puts "Scheduled for: #{broadcast.scheduled_at}" ``` ```go Go theme={null} broadcast, _ := client.Broadcasts.Send(context.TODO(), broadcastID, zavudev.BroadcastSendParams{ ScheduledAt: zavudev.String("2024-01-16T09:00:00.000Z"), }) fmt.Printf("Status: %s\n", broadcast.Status) // "scheduled" fmt.Printf("Scheduled for: %s\n", broadcast.ScheduledAt) ``` ```php PHP theme={null} $broadcast = $client->broadcasts->send($broadcastId, [ 'scheduledAt' => '2024-01-16T09:00:00.000Z', ]); echo "Status: {$broadcast->status}\n"; // "scheduled" echo "Scheduled for: {$broadcast->scheduledAt}\n"; ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/broadcasts/{broadcastId}/send \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "scheduledAt": "2024-01-16T09:00:00.000Z" }' ``` > [!TIP] > > Use scheduling to send campaigns at optimal times for your audience's timezone. For example, schedule a morning message to arrive at 9am in the recipient's local time. ## Rescheduling a Broadcast If you need to change the scheduled time, you can reschedule a broadcast that's in `scheduled` status: ```typescript TypeScript theme={null} const broadcast = await zavu.broadcasts.reschedule(broadcastId, { scheduledAt: "2024-01-17T14:00:00.000Z", }); console.log(`New scheduled time: ${broadcast.scheduledAt}`); ``` ```python Python theme={null} broadcast = zavu.broadcasts.reschedule( broadcast_id, scheduled_at="2024-01-17T14:00:00.000Z" ) print(f"New scheduled time: {broadcast.scheduled_at}") ``` ```ruby Ruby theme={null} broadcast = client.broadcasts.reschedule(broadcast_id, scheduled_at: "2024-01-17T14:00:00.000Z" ) puts "New scheduled time: #{broadcast.scheduled_at}" ``` ```go Go theme={null} broadcast, _ := client.Broadcasts.Reschedule(context.TODO(), broadcastID, zavudev.BroadcastRescheduleParams{ ScheduledAt: zavudev.String("2024-01-17T14:00:00.000Z"), }) fmt.Printf("New scheduled time: %s\n", broadcast.ScheduledAt) ``` ```php PHP theme={null} $broadcast = $client->broadcasts->reschedule($broadcastId, [ 'scheduledAt' => '2024-01-17T14:00:00.000Z', ]); echo "New scheduled time: {$broadcast->scheduledAt}\n"; ``` ```bash cURL theme={null} curl -X PATCH https://api.zavu.dev/v1/broadcasts/{broadcastId}/schedule \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "scheduledAt": "2024-01-17T14:00:00.000Z" }' ``` > [!NOTE] > > You can only reschedule broadcasts that are in `scheduled` status. The new time must be in the future. ## Pre-Send Validation Before sending, the API validates: | Validation | Error if Failed | | - | - | | Status is `draft`, `approved`, or `scheduled` | `broadcast_already_sending` | | At least 1 contact exists | `no_contacts` | | Sender is configured for channel | `sender_not_configured` | | Sufficient balance (SMS/Email) | `insufficient_balance` | | Template exists and approved (WhatsApp) | `template_not_approved` | ## Balance Reservation When you trigger a broadcast, Zavu **reserves the estimated cost** from your balance. This ensures funds are available for the entire campaign. ``` Available Balance: $100.00 Broadcast Cost: $25.00 (estimated) ─────── Reserved Balance: $25.00 (blocked) Usable Balance: $75.00 ``` ### How It Works **1. Cost Estimation** When you call `/send`, Zavu calculates the estimated cost based on contacts and channel rates **2. Balance Check** If your available balance (excluding other reservations) is less than the estimated cost, the request fails **3. Reservation Created** The estimated amount is reserved and cannot be used for other broadcasts or messages **4. Messages Sent** As messages are delivered, actual costs are deducted from the reservation **5. Reservation Released** When the broadcast completes or is cancelled, any unused reserved funds are released back to your available balance ### Checking Your Balance ```typescript theme={null} const billing = await zavu.balance.retrieve(); // `balance` is in cents. console.log(`Balance: $${(billing.balance / 100).toFixed(2)}`); ``` Reserved and available amounts are not part of the balance response. When a send is refused for funds, the `402` body carries them under `details`: ```json theme={null} { "code": "insufficient_balance", "message": "Insufficient balance. Available: $50.00", "details": { "totalBalance": 50, "reservedBalance": 25, "availableBalance": 25 } } ``` ### Response with Reservation When a broadcast starts, the response includes reservation details: ```json theme={null} { "id": "brd_abc123", "status": "sending", "totalContacts": 1500, "estimatedCost": 25.00, "reservedAmount": 25.00, "startedAt": "2024-01-15T10:30:00.000Z" } ``` ### Insufficient Balance If you don't have enough available balance, the send returns `402`: ```json theme={null} { "code": "insufficient_balance", "message": "Insufficient balance. Available: $10.50", "details": { "totalBalance": 25.50, "reservedBalance": 15.00, "availableBalance": 10.50 } } ``` The same object is also repeated under an `error` key, for integrations written against the older shape. Read the top-level `code`. > [!WARNING] > > Reserved funds are blocked until the broadcast completes or is cancelled. Plan your campaigns to avoid blocking funds needed for other messaging. ### Reservation Release | Scenario | What Happens | | - | - | | Broadcast completes | Unused reservation released immediately | | Broadcast cancelled | Full reservation released (minus already-sent messages) | | Actual cost \< estimated | Difference released when broadcast completes | | Actual cost > estimated | Additional amount charged from available balance | > [!NOTE] > > WhatsApp message costs are billed directly by Meta, not deducted from your Zavu balance. No reservation is created for WhatsApp-only broadcasts. ## Content Review All broadcasts go through an automated AI content review before sending. This protects both you and your recipients from policy violations. ### How Content Review Works **1. Submit for Sending** When you call `/send`, the broadcast enters `pending_review` status **2. AI Analysis** Content is analyzed for spam, phishing, prohibited content, and policy violations **3. Decision** Broadcast is either `approved` and proceeds, or `rejected` with feedback ### Review Statuses | Status | Description | | - | - | | `pending_review` | Content being analyzed | | `approved` | Review passed, broadcast proceeds | | `rejected` | Content rejected, needs editing | | `escalated` | Sent to human review | | `rejected_final` | Rejected by human review (cannot be appealed) | ### Handling Rejections If your broadcast is rejected, you'll receive details about the issue: ```json theme={null} { "id": "brd_abc123", "status": "rejected", "reviewResult": { "score": 0.35, "categories": ["spam_indicators", "missing_opt_out"], "reasoning": "Message contains promotional content without unsubscribe option", "flaggedContent": ["limited time offer", "act now"], "reviewedAt": "2024-01-15T10:31:00.000Z" }, "reviewAttempts": 1 } ``` ### Editing and Retrying After rejection, edit your broadcast content and retry the review: ```typescript TypeScript theme={null} // 1. Get the rejected broadcast const broadcast = await zavu.broadcasts.retrieve(broadcastId); if (broadcast.status === "rejected") { console.log("Rejection reason:", broadcast.reviewResult?.reasoning); // 2. Edit the content to address issues await zavu.broadcasts.update(broadcastId, { text: "Thanks for being a customer! Check out our latest products. Reply STOP to unsubscribe.", }); // 3. Retry the review const updated = await zavu.broadcasts.retryReview(broadcastId); console.log("New status:", updated.status); // "pending_review" } ``` ```python Python theme={null} # 1. Get the rejected broadcast broadcast = zavu.broadcasts.retrieve(broadcast_id) if broadcast.status == "rejected": print(f"Rejection reason: {broadcast.review_result.reasoning}") # 2. Edit the content to address issues zavu.broadcasts.update(broadcast_id, text="Thanks for being a customer! Check out our latest products. Reply STOP to unsubscribe." ) # 3. Retry the review. Not generated in the Python SDK yet, so call the # endpoint directly: POST /v1/broadcasts/{id}/retry-review res = httpx.post( f"https://api.zavu.dev/v1/broadcasts/{broadcast_id}/retry-review", headers={"Authorization": f"Bearer {os.environ['ZAVUDEV_API_KEY']}"}, ) print(f"New status: {res.json()['broadcast']['status']}") # "pending_review" ``` ```ruby Ruby theme={null} # 1. Get the rejected broadcast broadcast = client.broadcasts.retrieve(broadcast_id) if broadcast.status == "rejected" puts "Rejection reason: #{broadcast.review_result&.reasoning}" # 2. Edit the content to address issues client.broadcasts.update(broadcast_id, text: "Thanks for being a customer! Check out our latest products. Reply STOP to unsubscribe." ) # 3. Retry the review updated = client.broadcasts.retry_review(broadcast_id) puts "New status: #{updated.status}" # "pending_review" end ``` ```bash cURL theme={null} # 1. Update the content curl -X PATCH https://api.zavu.dev/v1/broadcasts/{broadcastId} \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "text": "Thanks for being a customer! Check out our latest products. Reply STOP to unsubscribe." }' # 2. Retry the review curl -X POST https://api.zavu.dev/v1/broadcasts/{broadcastId}/retry-review \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` > [!WARNING] > > You have a maximum of 3 review attempts per broadcast. After 3 rejections, you must escalate to manual review or create a new broadcast. ### Escalating to Manual Review If you believe your content was incorrectly rejected, escalate to the Zavu team: ```typescript TypeScript theme={null} const broadcast = await zavu.broadcasts.escalateReview(broadcastId); console.log("Status:", broadcast.status); // "escalated" // Zavu team will review and update status to "approved" or "rejected_final" ``` ```ruby Ruby theme={null} broadcast = client.broadcasts.escalate_review(broadcast_id) puts "Status: #{broadcast.status}" # "escalated" ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/broadcasts/{broadcastId}/escalate \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` > [!NOTE] > > Manual reviews are typically processed within 24 hours during business days. You'll receive a webhook notification when the review is complete. ### Content Guidelines To avoid rejections, ensure your broadcasts: | Requirement | Example | | - | - | | Include opt-out instructions | "Reply STOP to unsubscribe" | | Avoid spam trigger words | Avoid "FREE", "ACT NOW", "LIMITED TIME" in all caps | | Identify your business | Include your company name | | Don't impersonate | Never pretend to be banks, government, etc. | | Avoid URL shorteners | Use full URLs (see [URL Verification](/guides/url-verification/overview)) | ## Cancelling a Broadcast Cancel a broadcast from `draft`, `pending_review`, `pending_admin_review`, `approved`, `rejected`, `escalated`, `scheduled` or `sending`. A broadcast that is `paused`, `rejected_final` or already finished (`completed`, `cancelled`, `failed`) returns `400`: ```typescript TypeScript theme={null} const broadcast = await zavu.broadcasts.cancel(broadcastId); console.log(`Status: ${broadcast.status}`); // "cancelled" ``` ```python Python theme={null} broadcast = zavu.broadcasts.cancel(broadcast_id) print(f"Status: {broadcast.status}") # "cancelled" ``` ```ruby Ruby theme={null} broadcast = client.broadcasts.cancel(broadcast_id) puts "Status: #{broadcast.status}" # "cancelled" ``` ```go Go theme={null} broadcast, _ := client.Broadcasts.Cancel(context.TODO(), broadcastID) fmt.Printf("Status: %s\n", broadcast.Status) // "cancelled" ``` ```php PHP theme={null} $broadcast = $client->broadcasts->cancel($broadcastId); echo "Status: {$broadcast->status}\n"; // "cancelled" ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/broadcasts/{broadcastId}/cancel \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` ### What Happens on Cancel Whatever status it was in, the broadcast moves to `cancelled` and stays there: * Contacts still `pending` are marked `skipped`. * Any balance reserved for it is released. * A content review still running when it is cancelled cannot bring it back. > [!WARNING] > > Messages already queued or sent cannot be cancelled. Only pending contacts are skipped. ## Delivery Rate Limits Zavu automatically rate-limits delivery to prevent carrier throttling: | Channel | Rate | Notes | | - | - | - | | SMS | 10/second | Shared across all projects | | WhatsApp | 60/second | Per WhatsApp Business Account | | Email | 14/second | | For a 10,000 contact SMS broadcast: * **Estimated time**: \~17 minutes (10,000 / 10 per second) > [!TIP] > > Large broadcasts are processed in the background. Use the [progress endpoint](/guides/broadcasts/tracking-progress) to monitor delivery. ## Cost Estimation Get estimated cost before sending: ```typescript theme={null} const broadcast = await zavu.broadcasts.retrieve(broadcastId); console.log(`Estimated cost: $${broadcast.estimatedCost}`); console.log(`Total contacts: ${broadcast.totalContacts}`); ``` | Channel | Cost Model | | - | - | | SMS | Per-message (varies by country) | | WhatsApp | Free (24h window) or template pricing | | Email | \$0.02 per message | Messages also count toward your plan's monthly message limits, with usage-based overage applied beyond them. See your plan for the exact limits and rates. ## Complete Workflow Example ```typescript theme={null} import Zavudev from '@zavudev/sdk'; const zavu = new Zavudev({ apiKey: process.env['ZAVUDEV_API_KEY'], // This is the default and can be omitted }); async function sendPromotion(contacts: Array<{phone: string, name: string}>) { // 1. Create broadcast const broadcast = await zavu.broadcasts.create({ name: "Holiday Sale 2024", channel: "sms", text: "Hi {{name}}! Our holiday sale is live. Get 30% off with code HOLIDAY30. Shop now!", }); console.log(`Created broadcast: ${broadcast.id}`); // 2. Add contacts in batches const BATCH_SIZE = 1000; for (let i = 0; i < contacts.length; i += BATCH_SIZE) { const batch = contacts.slice(i, i + BATCH_SIZE); const result = await zavu.broadcasts.contacts.add(broadcast.id, { contacts: batch.map(c => ({ recipient: c.phone, templateVariables: { name: c.name }, })), }); console.log(`Batch ${Math.floor(i / BATCH_SIZE) + 1}: Added ${result.added} contacts`); } // 3. Check estimated cost const updated = await zavu.broadcasts.retrieve(broadcast.id); console.log(`Estimated cost: $${updated.estimatedCost}`); console.log(`Total contacts: ${updated.totalContacts}`); // 4. Send the broadcast await zavu.broadcasts.send(broadcast.id); console.log("Broadcast started!"); // 5. Poll for progress let complete = false; while (!complete) { await new Promise(r => setTimeout(r, 5000)); // Wait 5 seconds const progress = await zavu.broadcasts.progress(broadcast.id); console.log(`Progress: ${progress.percentComplete}% (${progress.delivered} delivered, ${progress.failed} failed)`); if (progress.status === "completed" || progress.status === "cancelled") { complete = true; } } console.log("Broadcast complete!"); } ``` ## Next Steps > [card] [Tracking Progress](/guides/broadcasts/tracking-progress) > > Monitor delivery progress in real-time # Tracking Progress Source: https://docs.zavu.dev/guides/broadcasts/tracking-progress Monitor broadcast delivery in real-time Track your broadcast's delivery progress with instant updates on sent, delivered, and failed messages. ## Get Progress ```typescript TypeScript theme={null} const progress = await zavu.broadcasts.progress(broadcastId); console.log(`Status: ${progress.status}`); console.log(`Progress: ${progress.percentComplete}%`); console.log(`Delivered: ${progress.delivered}/${progress.total}`); console.log(`Failed: ${progress.failed}`); ``` ```python Python theme={null} progress = zavu.broadcasts.progress(broadcast_id) print(f"Status: {progress.status}") print(f"Progress: {progress.percent_complete}%") print(f"Delivered: {progress.delivered}/{progress.total}") print(f"Failed: {progress.failed}") ``` ```ruby Ruby theme={null} progress = client.broadcasts.progress(broadcast_id) puts "Status: #{progress.status}" puts "Progress: #{progress.percent_complete}%" puts "Delivered: #{progress.delivered}/#{progress.total}" puts "Failed: #{progress.failed}" ``` ```go Go theme={null} progress, _ := client.Broadcasts.Progress(context.TODO(), broadcastID) fmt.Printf("Status: %s\n", progress.Status) fmt.Printf("Progress: %v%%\n", progress.PercentComplete) fmt.Printf("Delivered: %d/%d\n", progress.Delivered, progress.Total) fmt.Printf("Failed: %d\n", progress.Failed) ``` ```php PHP theme={null} $progress = $client->broadcasts->progress($broadcastId); echo "Status: {$progress->status}\n"; echo "Progress: {$progress->percentComplete}%\n"; echo "Delivered: {$progress->delivered}/{$progress->total}\n"; echo "Failed: {$progress->failed}\n"; ``` ```bash cURL theme={null} curl https://api.zavu.dev/v1/broadcasts/{broadcastId}/progress \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` ### Response ```json theme={null} { "broadcastId": "brd_abc123", "status": "sending", "total": 5000, "pending": 2500, "sending": 100, "sent": 350, "delivered": 2000, "failed": 50, "skipped": 0, "percentComplete": 48.0, "estimatedCost": 75.00, "reservedAmount": 75.00, "actualCost": 35.25, "startedAt": "2024-01-15T10:30:00.000Z", "estimatedCompletionAt": "2024-01-15T10:45:00.000Z" } ``` ## Progress Fields | Field | Type | Description | | - | - | - | | `broadcastId` | string | Broadcast identifier | | `status` | string | Current broadcast status | | `total` | number | Total contacts in broadcast | | `pending` | number | Not yet queued for sending | | `sending` | number | Currently being sent | | `sent` | number | Accepted by the provider, delivery not confirmed yet | | `delivered` | number | Delivery to the device confirmed | | `failed` | number | Failed to deliver | | `skipped` | number | Skipped (broadcast cancelled) | | `percentComplete` | number | Percentage complete (0-100) | | `estimatedCost` | number | Estimated total cost (USD) | | `reservedAmount` | number | Amount reserved from balance (USD) | | `actualCost` | number | Actual cost so far (USD) | | `startedAt` | string | When sending started | | `estimatedCompletionAt` | string | Estimated completion time | ## Polling for Updates Poll the progress endpoint at regular intervals: ```typescript theme={null} async function waitForCompletion(broadcastId: string) { const POLL_INTERVAL = 5000; // 5 seconds while (true) { const progress = await zavu.broadcasts.progress(broadcastId); console.log(`[${new Date().toISOString()}] ${progress.percentComplete}% complete`); console.log(` Delivered: ${progress.delivered}`); console.log(` Failed: ${progress.failed}`); console.log(` Pending: ${progress.pending}`); if (progress.status === "completed" || progress.status === "cancelled") { return progress; } await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL)); } } const finalProgress = await waitForCompletion(broadcastId); console.log(`Broadcast ${finalProgress.status}!`); ``` > [!TIP] > > For large broadcasts, use longer polling intervals (10-30 seconds) to reduce API calls. The progress updates are efficient and don't scan all contacts. ## Status Transitions ``` draft → scheduled → sending → completed ↓ ↓ cancelled cancelled ``` | Status | `percentComplete` | Description | | - | - | - | | `draft` | 0% | Still adding contacts | | `scheduled` | 0% | Waiting for scheduled time | | `sending` | 0-99% | Messages being delivered | | `completed` | 100% | All messages processed | | `cancelled` | varies | Stopped before completion | ## Viewing Individual Contacts Each recipient has one of these statuses: | Status | Meaning | | - | - | | `pending`, `queued`, `sending` | Not handed to the provider yet | | `sent` | The provider accepted the message. Delivery is not confirmed yet | | `delivered` | The channel confirmed delivery to the device. A WhatsApp read receipt also counts | | `failed` | Not delivered. A `sent` or `delivered` recipient moves here if the provider reports a failure later | | `skipped` | Not sent: the recipient opted out of the channel, or the broadcast was cancelled first | Channels that never report delivery leave their recipients at `sent`. Completion counts `sent` recipients as done, so a `completed` broadcast can still have `sent` recipients that move to `delivered` or `failed` afterwards. Get detailed status for each contact: ```typescript theme={null} // Get failed contacts const failed = await zavu.broadcasts.contacts.list(broadcastId, { status: "failed", limit: 100, }); for (const contact of failed.items) { console.log(`${contact.recipient}: ${contact.errorMessage}`); } ``` ### Contact Status Response ```json theme={null} { "items": [ { "id": "bc_abc123", "recipient": "+14155551234", "status": "failed", "errorCode": "30003", "errorMessage": "Unreachable destination", "processedAt": "2024-01-15T10:35:00.000Z" } ], "nextCursor": null } ``` ## Broadcast Analytics After completion, get final statistics: ```typescript theme={null} const broadcast = await zavu.broadcasts.retrieve(broadcastId); console.log("=== Broadcast Report ==="); console.log(`Name: ${broadcast.name}`); console.log(`Status: ${broadcast.status}`); console.log(`Total: ${broadcast.totalContacts}`); console.log(`Delivered: ${broadcast.deliveredCount}`); console.log(`Failed: ${broadcast.failedCount}`); console.log(`Delivery Rate: ${(broadcast.deliveredCount / broadcast.totalContacts * 100).toFixed(1)}%`); console.log(`Duration: ${calculateDuration(broadcast.startedAt, broadcast.status_changedAt)}`); console.log(`Actual Cost: $${broadcast.actualCost}`); ``` ## Error Analysis Common failure reasons: | Error Code | Description | Recommendation | | - | - | - | | `30003` | Unreachable destination | Invalid or disconnected number | | `30004` | Message blocked | Content filtered by carrier | | `30005` | Unknown destination | Number doesn't exist | | `30006` | Landline destination | Cannot SMS landlines | | `30007` | Carrier violation | Message rejected by carrier | | `rate_limited` | Too many messages | Temporary, will retry | | `insufficient_balance` | Out of credits | Top up balance | ### Export Failed Contacts ```typescript theme={null} async function exportFailedContacts(broadcastId: string) { const failed: string[] = []; let cursor: string | undefined; do { const result = await zavu.broadcasts.contacts.list(broadcastId, { status: "failed", limit: 100, cursor, }); for (const contact of result.items) { failed.push(`${contact.recipient},${contact.errorCode},${contact.errorMessage}`); } cursor = result.nextCursor ?? undefined; } while (cursor); // Write to CSV const csv = ["recipient,error_code,error_message", ...failed].join("\n"); fs.writeFileSync("failed_contacts.csv", csv); console.log(`Exported ${failed.length} failed contacts`); } ``` ## Real-time Updates with Webhooks For real-time updates without polling, configure webhooks: ```typescript theme={null} // In your webhook handler app.post("/webhooks/zavu", (req, res) => { const event = req.body; if (event.type === "broadcast.status_changed") { console.log(`Broadcast ${event.data.broadcastId} completed!`); console.log(`Delivered: ${event.data.deliveredCount}`); console.log(`Failed: ${event.data.failedCount}`); } res.status(200).send("OK"); }); ``` ### Broadcast Events | Event | Description | | - | - | | `broadcast.started` | Broadcast started sending | | `broadcast.status_changed` | All messages processed | | `broadcast.cancelled` | Broadcast was cancelled | ## Best Practices **1. Use appropriate polling intervals** 5 seconds for small broadcasts, 30 seconds for large ones **2. Handle partial failures** Some contacts may fail while others succeed - check both counts **3. Export and analyze failures** Review failed contacts to improve your contact list quality **4. Set up webhooks for production** Avoid continuous polling by using webhook notifications ## Complete Monitoring Example ```typescript theme={null} import Zavudev from '@zavudev/sdk'; const zavu = new Zavudev({ apiKey: process.env['ZAVUDEV_API_KEY'], // This is the default and can be omitted }); async function monitorBroadcast(broadcastId: string) { console.log(`Monitoring broadcast: ${broadcastId}\n`); let lastDelivered = 0; let lastFailed = 0; while (true) { const progress = await zavu.broadcasts.progress(broadcastId); // Calculate rate const newDelivered = progress.delivered - lastDelivered; const newFailed = progress.failed - lastFailed; console.log(`[${progress.percentComplete.toFixed(1)}%] ` + `Delivered: ${progress.delivered} (+${newDelivered}) | ` + `Failed: ${progress.failed} (+${newFailed}) | ` + `Pending: ${progress.pending}`); lastDelivered = progress.delivered; lastFailed = progress.failed; if (progress.status === "completed") { console.log("\n=== Broadcast Complete ==="); console.log(`Total: ${progress.total}`); console.log(`Delivered: ${progress.delivered} (${(progress.delivered / progress.total * 100).toFixed(1)}%)`); console.log(`Failed: ${progress.failed} (${(progress.failed / progress.total * 100).toFixed(1)}%)`); break; } if (progress.status === "cancelled") { console.log("\n=== Broadcast Cancelled ==="); console.log(`Skipped: ${progress.skipped} contacts`); break; } await new Promise(r => setTimeout(r, 5000)); } } monitorBroadcast("brd_abc123"); ``` # npx zavudev agents Source: https://docs.zavu.dev/guides/cli/agents Create, test, connect and inspect agents from the terminal An agent is a standalone object with its own id. It can answer on several senders, and it can exist with none while you build it. ```bash theme={null} npx zavudev agents list ``` ``` id name kind enabled senders model qd75detym58c6has4sfrye3vws8b6ygt Atlas voice yes 1 openai/gpt-5.6-luna qd7ck28evcskdc3xx4wzevtmeh8b6bcm Pizza Desk text no 0 gpt-4o-mini ``` `kind` reflects what the agent actually is — an agent named for voice whose `voice` block is missing reads as `text`, which is usually the thing you wanted to know. ## Test an agent The command you will use most. It runs the real prompt, model and knowledge base and prints what the agent **would** reply. Nothing is delivered, nothing is charged, no execution is logged — so it is safe on every edit. ```bash theme={null} npx zavudev agents test --agent --message "where is order ORD-001?" ``` ``` Your order ORD-001 shipped on Tuesday and arrives Thursday. 1418→40 tokens · 5249ms · 1 knowledge chunk(s) ``` | Flag | | | - | - | | `--agent ` | The agent. Get it from `agents list`. | | `--sender ` | Address it by sender instead (resolves to that sender's one agent) | | `--message ` | What to say | | `--turn ` | Prior turns, alternating user/assistant, oldest first. Repeatable. | | `--no-knowledge` | Skip retrieval, to isolate prompt behaviour from the knowledge base | | `--json` | Raw response | Multi-turn: ```bash theme={null} npx zavudev agents test --agent \ --turn "I need to change my booking" --turn "Sure — which one?" \ --message "the one on Friday" ``` > [!WARNING] > > **Read the warnings.** A dry run cannot prove everything, and it tells you what > it could not: an agent that is still disabled, tools its channels will never > call, flows it did not evaluate, contact metadata that exists live but not here. > Treat them as part of the result — a good answer here is not proof the agent > works on a real channel. ## Start from a factory agent ```bash theme={null} npx zavudev agents catalog # what is available npx zavudev agents pull kepler --dir kepler # scaffold it locally cd kepler && npm install npx zavudev deploy ``` `pull` writes a real TypeScript project: `index.ts` with the agent and its skills, a `tsconfig.json`, and `@zavudev/functions` as a dependency so your editor and `tsc` resolve it. Edit it freely — the code is the source of truth and `npx zavudev deploy` reconciles whatever it declares. | Flag | | | - | - | | `--dir ` | Where to scaffold. Defaults to `./`. | | `--sender ` | Sets the `SENDER_ID` secret for you | | `--calendar ` | For booking agents: `webhook` (default) or `calcom` | ### Booking agents Kepler, Hopper and Fermi book meetings. By default they scaffold against a generic webhook — an HTTPS endpoint **you** build and host that returns open slots and books them. Until it exists the agent cannot book, and says so rather than inventing a time. Point it at Cal.com instead and it works with an API key: ```bash theme={null} npx zavudev agents pull kepler --calendar calcom ``` `pull` then lists every secret the agent needs, with a hint for each: ``` npx zavudev fn secrets set CALCOM_API_KEY ↳ Cal.com API key (Settings → Developer → API keys). npx zavudev fn secrets set CALCOM_EVENT_TYPE_ID ↳ Numeric id of the Cal.com event type to book. ``` The Cal.com client only reports a booking when Cal.com accepted it. An event type that requires confirmation comes back pending, and telling a caller they are booked at that point is the worst outcome on that line. `npx zavudev agents init` walks the same path interactively: pick or create a sender, pick an agent, get ready to deploy. ## Create one from scratch ```bash theme={null} npx zavudev agents create --sender \ --name "Order desk" --provider zavu --model openai/gpt-5.6-luna \ --channels whatsapp,sms \ --prompt "You are the order desk for Tony's Pizza. Answer in one or two sentences." ``` Use `--prompt-file` for anything longer than a line — terminal paste mangles newlines. `--data` / `--file` take a full JSON body for advanced fields. > [!NOTE] > > An agent is created **disabled** and answers nobody until you turn it on. Pass > `--enabled true` on create, or `npx zavudev agents update --sender --enabled true` > later. The CLI says so when it happens. ## Connect senders A sender is the identity a conversation happens on. An agent can answer on several; **a sender answers with at most one agent.** ```bash theme={null} npx zavudev agents senders connect --agent --sender npx zavudev agents senders disconnect --agent --sender ``` Connecting a sender that is already in use is rejected, naming the agent that holds it — otherwise you would have an agent that looks connected and never receives a message. ## Tools ```bash theme={null} npx zavudev agents tools list --sender npx zavudev agents tools test --sender --params '{"orderId":"ORD-1"}' ``` > [!NOTE] > > Tools are offered to the model on **every channel**: plain text, voice, and > inside a flow's `tool` step, with up to 5 tool rounds per reply. `agents test` > never executes tools (a dry run must not cause side effects) — it warns when > tools were skipped. See [how agents work](/concepts/agent-architecture). Tools declared in code with `defineTool` are managed by their function: they appear here read-only and are changed by redeploying. To run a handler in isolation, without deploying: ```bash theme={null} npx zavudev fn invoke --tool get_order --args '{"orderId":"ORD-1"}' ``` ## Knowledge bases ```bash theme={null} npx zavudev agents knowledge-bases create --sender --name "Product docs" npx zavudev agents knowledge-bases documents add --sender --kb \ --title "Pricing" --content-file ./pricing.md npx zavudev agents knowledge-bases documents list --sender --kb ``` Processing takes a few seconds. Verify retrieval actually fires with `agents test` — it reports how many chunks the answer used. > [!WARNING] > > A prompt that says *"only state what the documentation returns"* with no > documents attached does not refuse. It invents. Attach the documents before > relying on that instruction. ## Flows ```bash theme={null} npx zavudev agents flows list --sender npx zavudev agents flows create --sender --file flow.json ``` A flow is a deterministic sequence with a trigger. Steps take a `config` object; a `tool` step names its tool through `config.toolName`, or the older `config.toolId`. Either field is accepted, and either one may hold the tool's name or its id. A flow naming a tool the agent does not have is rejected at creation, listing the ones it does have. Flows are **not** evaluated by `agents test` — they run on a real message, and `agents test` says so when the agent has any. ## Inspect what happened ```bash theme={null} npx zavudev agents stats --sender npx zavudev agents executions list --sender npx zavudev agents executions get --sender npx zavudev agents reset-thread --sender ``` `executions get` carries `errorMessage` and `responseText` — the place to look when a live reply went wrong. `reset-thread` closes the conversation and any flow session for one contact so the next inbound starts fresh. Dry runs do not appear in `stats` or `executions`; they are not executions. ## Calls ```bash theme={null} npx zavudev calls list --status completed npx zavudev calls get # includes the transcript, turn by turn npx zavudev calls hangup ``` `calls get` prints the conversation including tool calls. It is the only record of what the agent actually said, and the first place to look after a call went wrong. ```bash theme={null} npx zavudev calls create --to +14155551234 ``` > [!WARNING] > > `calls create` places a real outbound call, billed per connected minute plus > telephony. `--greeting`, `--language` and `--max-minutes` override the agent's > configuration for that call only. # npx zavudev functions Source: https://docs.zavu.dev/guides/cli/functions Scaffold, deploy, invoke, inspect and roll back your code `npx zavudev functions` (alias `fn`) drives Zavu Functions — your TypeScript running on every messaging event. The most-used subcommands are also available at the top level: `npx zavudev deploy` is `npx zavudev fn deploy`. ## Scaffold ```bash theme={null} npx zavudev fn init --name order-bot --template blank ``` Writes `index.ts`, a `package.json` declaring `@zavudev/functions`, a `tsconfig.json`, and `.zavu/config.json` holding the function's id. Run `npx zavudev fn init --help` for the current template list. > [!NOTE] > > **Commit `.zavu/`.** It holds the `functionId`, and there is no command to look > one up — a teammate who clones without it cannot deploy. To start from a working agent instead of a blank file, see [`npx zavudev agents pull`](/guides/cli/agents). ## Deploy ```bash theme={null} npx zavudev deploy ``` ``` › Deploying Order Bot (order-bot)… deployment id: nx7v0h30epeh2s7ppxcn9mpan58b6yre › status: active ℹ Agents synced: + Bella ℹ Tools synced: + lookup_order ✓ Deployed in 11.5s ``` The summary lists what the deploy did to your agents and tools. **Read the lines above the ✓** — they print before the success line. There is no ✓ at all, and the command exits non-zero, in **two** cases: 1. Your declarations were not synced. The Lambda is live and its agents and tools are unchanged. 2. Your declarations synced and something in them cannot work — an agent no connected sender can reach, for instance. Everything deployed; the result is not usable as-is. A CI job gating on the exit code will fail on both, which is the intent: the second one is the deploy that otherwise looks green and answers nobody. Warnings you may see, and what they mean: | Warning | What happened | | - | - | | `manifest probe threw: …` | Your code failed to load. Nothing was synced; fix and deploy again. Exits non-zero. | | `agent … cannot receive anything` | Its `channels` name nothing any connected sender carries. Deployed, enabled, and permanently silent. Exits non-zero. | | `agent … triggers only on … so messages on other channels are dropped` | A sender it is connected to receives on a channel the agent does not list. Those messages are discarded before the agent sees them. Use `["*"]` unless you are deliberately excluding one. | | `agent … was created without a sender` | It will not answer anything until a sender is attached. | | `tools are synced but will NOT be called on ` | Historical warning from before tools ran on the text path; on current deployments tools run on every channel. | | `sender … now has N agents` | Only the first answers. The rest are unreachable — give this function its own sender. | `npx zavudev fn push` updates the draft **without** deploying. Useful to save work in progress; nothing changes in production until `deploy`. ## Run it locally ```bash theme={null} # Call one tool handler directly, with no deploy: npx zavudev fn invoke --tool lookup_order --args '{"orderId":"ORD-001"}' # Or dispatch a synthetic event at your default export: npx zavudev fn invoke --event message.inbound \ --data '{"from":"+14155551234","text":"hola"}' ``` | Flag | | | - | - | | `--tool ` / `--args ` / `--args-file ` | Call one `defineTool` handler | | `--event ` / `--data ` / `--data-file ` | Dispatch an event | | `--source ` | Entry file. Defaults to `index.ts`. | | `--live` | Make real API calls. Off by default: outbound HTTP is stubbed and logged. | > [!NOTE] > > `fn invoke` runs your code with [Bun](https://bun.sh). Every other command runs > under plain Node. This is the cheapest feedback in the product: it exercises your handler exactly as production will, in milliseconds, without deploying or spending anything. ## Logs ```bash theme={null} npx zavudev fn logs # recent invocations npx zavudev fn logs --tail # follow ``` Run it from the function's directory. Lambda's own noise is filtered out and internal identifiers are redacted. ## Secrets ```bash theme={null} npx zavudev fn secrets list npx zavudev fn secrets set STRIPE_KEY sk_live_… npx zavudev fn secrets set STRIPE_KEY --from-file ./key.txt npx zavudev fn secrets unset STRIPE_KEY ``` Values are write-only: `list` shows the last four characters, never the secret. Reach them in your handler with `process.env.STRIPE_KEY`. > [!WARNING] > > Secrets apply on the **next deploy**. Setting one does not change the running > function until you run `npx zavudev deploy`. ## Triggers ```bash theme={null} npx zavudev fn triggers list npx zavudev fn triggers events # what you can subscribe to npx zavudev fn triggers add --events message.inbound npx zavudev fn triggers toggle --off npx zavudev fn triggers remove ``` A trigger subscribes the function to an event, optionally scoped to one sender. A function that only declares an agent and its tools needs no triggers — the agent path invokes it directly. ## Versions and rollback ```bash theme={null} npx zavudev fn versions list npx zavudev fn rollback ``` Every deploy is a version with its own record. `rollback` republishes a previous one's source and dependencies; it always asks for confirmation. Secrets are not rolled back. ## Delete ```bash theme={null} npx zavudev fn delete # in the function's directory npx zavudev fn delete ``` Removes the function and everything it owns — its triggers, secrets, deployment history, and the agents and tools it manages. It asks you to type the slug first, because there is no undo. ## Where to go next > [card] [How agents work](/concepts/agent-architecture) > > What a deploy actually does, with diagrams. > [card] [npx zavudev agents](/guides/cli/agents) > > Test, connect and inspect the agents your code declares. # CLI overview Source: https://docs.zavu.dev/guides/cli/overview Install, authenticate, and the loop you will actually live in The Zavu CLI drives everything the API does, plus the things only a terminal can: scaffolding a function, running a tool handler locally, and asking an agent what it would say before anyone hears it. ```bash theme={null} npx zavudev --help ``` No install needed — `npx` fetches it. If you use it daily: ```bash theme={null} npm i -g zavudev # then just `zavu` ``` > [!NOTE] > > The package and the binary are both `zavudev`. `npx zavudev ` always runs > the published version; a global install gives you the same `zavudev `. ## Authenticate ```bash theme={null} npx zavudev login # opens your browser, saves a fresh API key npx zavudev whoami # which project and team you are pointed at npx zavudev logout ``` Credentials land in `~/.zavu/credentials.json`. ### Without a browser For CI, a container, or a coding agent, set the key in the environment instead. No `login`, no writable home directory: ```bash theme={null} export ZAVUDEV_API_KEY=zv_live_… npx zavudev agents list ``` | Variable | Effect | | - | - | | `ZAVUDEV_API_KEY` | Used instead of the saved credentials file | | `ZAVUDEV_API_BASE_URL` | Points at a non-production deployment | | `CI` / `ZAVU_NO_ANIMATE` | Disables terminal animation (also off automatically when output is piped) | ## The loop Most of what you do is free and instant. Only the last step costs anything. ```mermaid theme={null} flowchart LR A["npx zavudev agents pull"] --> B["edit index.ts"] B --> C["npx zavudev fn invoke --tool"] C --> D["npx zavudev deploy"] D --> E["npx zavudev agents test"] E --> B E --> F["a real message or call"] ``` | Command | Cost | What it tells you | | - | - | - | | `npx zavudev fn invoke --tool` | free | A handler does what you think, without deploying | | `npx zavudev deploy` | free | Your declarations are live | | `npx zavudev agents list` | free | What actually landed | | `npx zavudev agents test` | free | What the agent would reply | | `npx zavudev calls create` | billed | Everything else | ## Reading the output **Warnings before the checkmark.** `npx zavudev deploy` prints its summary and any warnings *above* the success line, and exits non-zero when your declarations were not synced. Those warnings cover the cases where a green deploy did not do what it looks like. **Errors name the field.** A rejected body tells you which one: ``` ✗ Invalid request body [invalid_request] steps.0.nextStepId: Expected string, received null ``` **`--json` for scripting.** Most list and read commands take it. Tables are for humans; `--json` is stable. **Exit codes.** `0` success, `1` failure, `2` a local invoke whose target was not found. Safe to use in CI. ## Getting help ```bash theme={null} npx zavudev --help npx zavudev agents --help npx zavudev agents create --help npx zavudev help agents create # same thing, any depth ``` ## Command groups | Group | What it covers | | - | - | | [`agents`](/guides/cli/agents) | Create, test, connect, and inspect agents | | [`functions`](/guides/cli/functions) (alias `fn`) | Scaffold, deploy, invoke, logs, secrets, rollback | | `calls` | Voice calls and transcripts — see [agents](/guides/cli/agents#calls) | | `messages` | Send and inspect messages | | `senders` | Sender profiles: numbers, WhatsApp, email | | `contacts`, `broadcasts`, `templates` | Contacts, campaigns, WhatsApp templates | | `phone-numbers` | Search, buy, assign | | `email-domains` | Add and verify sending domains | | `10dlc`, `addresses`, `documents`, `urls` | Compliance | | `sub-accounts`, `invitations` | White-label projects and onboarding | | `balance`, `introspect`, `exports` | Account and utilities | Every group answers `--help` with its own subcommands and flags. That is the authority — this site describes the same thing in prose. # Contacts Source: https://docs.zavu.dev/guides/contacts/overview Manage contacts and recipient information Contacts are automatically created when you send messages. Each contact represents a unique recipient (phone number or email) and stores metadata about their messaging preferences and history. ## How Contacts Work When you send a message to a new recipient, Zavu automatically: 1. Creates a contact record 2. Detects the country from the phone number 3. Determines available messaging channels 4. Captures the WhatsApp profile name (if available) ```typescript theme={null} // Sending to a new recipient creates a contact const message = await zavu.messages.send({ to: "+14155551234", text: "Welcome!", }); // Contact is now available const contact = await zavu.contacts.retrieveByPhone("+14155551234"); console.log(contact.id); // "con_abc123" ``` ## Contact Properties | Property | Description | | - | - | | `id` | Unique contact identifier | | `phoneNumber` | E.164 formatted phone number | | `countryCode` | Two-letter country code (e.g., "US") | | `profileName` | WhatsApp display name (if available) | | `availableChannels` | Channels this contact can receive (sms, whatsapp, etc.) | | `defaultChannel` | Preferred channel for auto-routing | | `verified` | Whether the contact has been verified | | `metadata` | Custom key-value data | ## Get Contact by ID ```typescript TypeScript theme={null} import Zavudev from '@zavudev/sdk'; const zavu = new Zavudev(); const contact = await zavu.contacts.retrieve("con_abc123"); console.log(`Phone: ${contact.phoneNumber}`); console.log(`Country: ${contact.countryCode}`); console.log(`Channels: ${contact.availableChannels.join(", ")}`); console.log(`Profile: ${contact.profileName || "Unknown"}`); ``` ```python Python theme={null} from zavudev import Zavudev zavu = Zavudev() contact = zavu.contacts.retrieve("con_abc123") print(f"Phone: {contact.phone_number}") print(f"Country: {contact.country_code}") print(f"Channels: {', '.join(contact.available_channels)}") print(f"Profile: {contact.profile_name or 'Unknown'}") ``` ```ruby Ruby theme={null} require "zavudev" client = Zavudev::Client.new(api_key: ENV["ZAVUDEV_API_KEY"]) contact = client.contacts.retrieve("con_abc123") puts "Phone: #{contact.phone_number}" puts "Country: #{contact.country_code}" puts "Channels: #{contact.available_channels.join(', ')}" puts "Profile: #{contact.profile_name || 'Unknown'}" ``` ```go Go theme={null} contact, _ := client.Contacts.Get(context.TODO(), "con_abc123") fmt.Println("Phone:", contact.PhoneNumber) fmt.Println("Country:", contact.CountryCode) fmt.Println("Channels:", contact.AvailableChannels) fmt.Println("Profile:", contact.ProfileName) ``` ```php PHP theme={null} contacts->retrieve('con_abc123'); echo "Phone: " . $contact->phoneNumber . "\n"; echo "Country: " . $contact->countryCode . "\n"; echo "Channels: " . implode(', ', $contact->availableChannels) . "\n"; echo "Profile: " . ($contact->profileName ?? 'Unknown') . "\n"; ``` ```bash cURL theme={null} curl https://api.zavu.dev/v1/contacts/{contactId} \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` ### Response ```json theme={null} { "id": "con_abc123", "phoneNumber": "+14155551234", "countryCode": "US", "profileName": "John Doe", "availableChannels": ["sms", "whatsapp"], "defaultChannel": "whatsapp", "verified": true, "metadata": { "tier": "premium", "source": "website" }, "createdAt": "2024-01-15T10:30:00.000Z", "updatedAt": "2024-01-15T12:00:00.000Z" } ``` ## Get Contact by Phone Number Look up a contact using their phone number: ```typescript TypeScript theme={null} const contact = await zavu.contacts.retrieveByPhone("+14155551234"); if (contact) { console.log(`Found contact: ${contact.id}`); console.log(`Available channels: ${contact.availableChannels}`); } else { console.log("Contact not found"); } ``` ```python Python theme={null} contact = zavu.contacts.retrieve_by_phone("+14155551234") if contact: print(f"Found contact: {contact.id}") print(f"Available channels: {contact.available_channels}") else: print("Contact not found") ``` ```ruby Ruby theme={null} contact = client.contacts.retrieve_by_phone("+14155551234") if contact puts "Found contact: #{contact.id}" puts "Available channels: #{contact.available_channels}" else puts "Contact not found" end ``` ```go Go theme={null} contact, err := client.Contacts.GetByPhone(context.TODO(), "+14155551234") if err != nil { fmt.Println("Contact not found") } else { fmt.Println("Found contact:", contact.ID) fmt.Println("Available channels:", contact.AvailableChannels) } ``` ```php PHP theme={null} contacts->retrieveByPhone('+14155551234'); if ($contact) { echo "Found contact: " . $contact->id . "\n"; echo "Available channels: " . implode(', ', $contact->availableChannels) . "\n"; } else { echo "Contact not found\n"; } ``` ```bash cURL theme={null} # URL-encode the phone number (+14155551234 becomes %2B14155551234) curl https://api.zavu.dev/v1/contacts/phone/%2B14155551234 \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` > [!TIP] > > Use this endpoint to check if a recipient already exists before sending messages, or to retrieve their metadata for personalization. ## List Contacts Retrieve all contacts with pagination: ```typescript TypeScript theme={null} // List all contacts const { items, nextCursor } = await zavu.contacts.list({ limit: 50, }); for (const contact of items) { console.log(`${contact.id}: ${contact.phoneNumber}`); } // Paginate through all contacts let cursor: string | undefined; const allContacts = []; do { const result = await zavu.contacts.list({ cursor, limit: 100 }); allContacts.push(...result.items); cursor = result.nextCursor ?? undefined; } while (cursor); console.log(`Total contacts: ${allContacts.length}`); ``` ```python Python theme={null} # List all contacts result = zavu.contacts.list(limit=50) for contact in result.items: print(f"{contact.id}: {contact.phone_number}") # Paginate through all contacts cursor = None all_contacts = [] while True: result = zavu.contacts.list(cursor=cursor, limit=100) all_contacts.extend(result.items) cursor = result.next_cursor if not cursor: break print(f"Total contacts: {len(all_contacts)}") ``` ```ruby Ruby theme={null} # List all contacts result = client.contacts.list(limit: 50) result.items.each do |contact| puts "#{contact.id}: #{contact.phone_number}" end # Paginate through all contacts cursor = nil all_contacts = [] loop do result = client.contacts.list(cursor: cursor, limit: 100) all_contacts.concat(result.items) cursor = result.next_cursor break unless cursor end puts "Total contacts: #{all_contacts.length}" ``` ```go Go theme={null} // List all contacts result, _ := client.Contacts.List(context.TODO(), zavudev.ContactListParams{ Limit: zavudev.Int(50), }) for _, contact := range result.Items { fmt.Printf("%s: %s\n", contact.ID, contact.PhoneNumber) } ``` ```php PHP theme={null} contacts->list(['limit' => 50]); foreach ($result->items as $contact) { echo "{$contact->id}: {$contact->phoneNumber}\n"; } // Paginate through all contacts $cursor = null; $allContacts = []; do { $result = $client->contacts->list(['cursor' => $cursor, 'limit' => 100]); $allContacts = array_merge($allContacts, $result->items); $cursor = $result->nextCursor; } while ($cursor); echo "Total contacts: " . count($allContacts) . "\n"; ``` ```bash cURL theme={null} curl "https://api.zavu.dev/v1/contacts?limit=50" \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` ### Filter by Phone Number `phoneNumber` is an **exact** match on the contact's primary phone number, in E.164. It is not a prefix filter — use `search` for partial matches. ```typescript theme={null} const { items } = await zavu.contacts.list({ phoneNumber: "+14155551234", }); ``` ### Search `search` matches the contact's name — both `displayName` and the WhatsApp profile name — as well as its phone numbers and email addresses. It is case- and accent-insensitive, so `jose` finds "José". ```typescript theme={null} const { items } = await zavu.contacts.list({ search: "marta", limit: 25 }); ``` A phone number also matches on a trailing fragment, which is how people usually remember them: ```typescript theme={null} // finds +14155551234 const { items } = await zavu.contacts.list({ search: "5551234" }); ``` > [!NOTE] > > Contacts created automatically from an inbound message have no `displayName`, so > they only match on their identifier until you name one with > [Update Contact](#update-contact). > [!WARNING] > > Search results come back in relevance order, not newest-first. `cursor` is opaque > in both modes — pass back exactly what the previous response returned, and start > a fresh pagination run whenever the search term changes. ### Filter by Tag Tags group contacts into an audience you send to more than once. Filter by name: ```typescript theme={null} const { items } = await zavu.contacts.list({ tag: "vip" }); ``` Repeat the parameter to require **every** tag, not any of them: ```bash theme={null} curl "https://api.zavu.dev/v1/contacts?tag=vip&tag=chile" \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` > [!WARNING] > > An unknown tag returns `400`. A typo that silently matched every contact would > be a worse answer than an error. > [!NOTE] > > Tags are created, renamed and assigned from the dashboard. There is no tag CRUD > in the API yet. ## Update Contact Update a contact's default channel or metadata: ```typescript TypeScript theme={null} const contact = await zavu.contacts.update("con_abc123", { defaultChannel: "whatsapp", metadata: { name: "John Doe", tier: "premium", source: "mobile_app", }, }); console.log(`Updated default channel: ${contact.defaultChannel}`); ``` ```python Python theme={null} contact = zavu.contacts.update( "con_abc123", default_channel="whatsapp", metadata={ "name": "John Doe", "tier": "premium", "source": "mobile_app" } ) print(f"Updated default channel: {contact.default_channel}") ``` ```ruby Ruby theme={null} contact = client.contacts.update( "con_abc123", default_channel: "whatsapp", metadata: { "name" => "John Doe", "tier" => "premium", "source" => "mobile_app" } ) puts "Updated default channel: #{contact.default_channel}" ``` ```go Go theme={null} contact, _ := client.Contacts.Update(context.TODO(), "con_abc123", zavudev.ContactUpdateParams{ DefaultChannel: zavudev.String("whatsapp"), Metadata: map[string]string{ "name": "John Doe", "tier": "premium", "source": "mobile_app", }, }) fmt.Println("Updated default channel:", contact.DefaultChannel) ``` ```php PHP theme={null} contacts->update('con_abc123', [ 'defaultChannel' => 'whatsapp', 'metadata' => [ 'name' => 'John Doe', 'tier' => 'premium', 'source' => 'mobile_app', ], ]); echo "Updated default channel: " . $contact->defaultChannel . "\n"; ``` ```bash cURL theme={null} curl -X PATCH https://api.zavu.dev/v1/contacts/{contactId} \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "defaultChannel": "whatsapp", "metadata": { "name": "John Doe", "tier": "premium" } }' ``` ### Updateable Fields | Field | Description | | - | - | | `displayName` | The contact's name. Pass `null` to clear it and fall back to the identifier. | | `defaultChannel` | Preferred channel for smart routing (sms, whatsapp, telegram, email) | | `metadata` | Custom key-value pairs (merged with existing metadata) | > [!NOTE] > > To change a contact's phone number or email address, add or remove a channel — > identifiers are not updated through this endpoint. > [!NOTE] > > Setting `defaultChannel` affects [smart routing](/guides/sending-messages/smart-routing). When you send a message with `channel: "auto"`, Zavu will prefer the contact's default channel. ## Contact Metadata Store custom data on contacts for personalization and segmentation: ```typescript theme={null} // Add customer data await zavu.contacts.update("con_abc123", { metadata: { customerId: "cust_12345", plan: "enterprise", signupDate: "2024-01-15", preferredLanguage: "es", }, }); // Use in messages const contact = await zavu.contacts.retrieve("con_abc123"); const message = await zavu.messages.send({ to: contact.phoneNumber, text: `Hola ${contact.metadata?.customerId}! Your ${contact.metadata?.plan} plan is active.`, }); ``` > [!NOTE] > > Metadata values must be strings. Store complex data as JSON strings if needed. ## Available Channels The `availableChannels` array indicates which channels can reach this contact: | Channel | When Available | | - | - | | `sms` | Valid mobile or landline number | | `whatsapp` | Contact has WhatsApp (detected on first message or reply) | | `telegram` | Contact has connected via Telegram bot | | `email` | Contact has email address on file | ```typescript theme={null} const contact = await zavu.contacts.retrieve("con_abc123"); if (contact.availableChannels.includes("whatsapp")) { // Send via WhatsApp for richer experience await zavu.messages.send({ to: contact.phoneNumber, channel: "whatsapp", text: "Hello via WhatsApp!", }); } else { // Fall back to SMS await zavu.messages.send({ to: contact.phoneNumber, channel: "sms", text: "Hello via SMS!", }); } ``` ## Phone Number Introspection Validate a phone number and check available channels without creating a contact: ```typescript TypeScript theme={null} const result = await zavu.introspect.validatePhone({ phoneNumber: "+14155551234", }); console.log(`Valid: ${result.validNumber}`); console.log(`Country: ${result.countryCode}`); console.log(`Format: ${result.nationalFormat}`); console.log(`Type: ${result.lineType}`); console.log(`Carrier: ${result.carrier?.name}`); console.log(`Channels: ${result.availableChannels}`); ``` ```ruby Ruby theme={null} result = client.introspect.validate_phone(phone_number: "+14155551234") puts "Valid: #{result.valid_number}" puts "Country: #{result.country_code}" puts "Format: #{result.national_format}" puts "Type: #{result.line_type}" puts "Carrier: #{result.carrier&.name}" puts "Channels: #{result.available_channels}" ``` ```go Go theme={null} result, _ := client.Introspect.Phone(context.TODO(), zavudev.PhoneIntrospectionParams{ PhoneNumber: zavudev.String("+14155551234"), }) fmt.Println("Valid:", result.ValidNumber) fmt.Println("Country:", result.CountryCode) fmt.Println("Format:", result.NationalFormat) fmt.Println("Type:", result.LineType) fmt.Println("Channels:", result.AvailableChannels) ``` ```php PHP theme={null} introspect->phone([ 'phoneNumber' => '+14155551234', ]); echo "Valid: " . ($result->validNumber ? 'true' : 'false') . "\n"; echo "Country: " . $result->countryCode . "\n"; echo "Format: " . $result->nationalFormat . "\n"; echo "Type: " . $result->lineType . "\n"; echo "Carrier: " . ($result->carrier->name ?? 'Unknown') . "\n"; echo "Channels: " . implode(', ', $result->availableChannels) . "\n"; ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/introspect/phone \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "phoneNumber": "+14155551234" }' ``` ### Response ```json theme={null} { "phoneNumber": "+14155551234", "countryCode": "US", "validNumber": true, "nationalFormat": "(415) 555-1234", "lineType": "mobile", "carrier": { "name": "Verizon Wireless", "type": "mobile" }, "availableChannels": ["sms", "whatsapp"] } ``` > [!TIP] > > Use introspection to validate phone numbers before adding them to broadcasts or CRM systems. ## Complete Example ```typescript theme={null} import Zavudev from '@zavudev/sdk'; const zavu = new Zavudev(); async function processNewCustomer(phone: string, customerData: { name: string; email: string; plan: string; }) { // 1. Validate the phone number const introspection = await zavu.introspect.validatePhone({ phoneNumber: phone }); if (!introspection.validNumber) { throw new Error("Invalid phone number"); } console.log(`Valid ${introspection.lineType} number from ${introspection.countryCode}`); // 2. Send welcome message (creates contact automatically) const message = await zavu.messages.send({ to: phone, channel: "auto", // Smart routing based on available channels text: `Welcome ${customerData.name}! Your ${customerData.plan} account is ready.`, }); console.log(`Welcome message sent: ${message.id}`); // 3. Update contact with customer data const contact = await zavu.contacts.retrieveByPhone(phone); await zavu.contacts.update(contact.id, { defaultChannel: introspection.availableChannels.includes("whatsapp") ? "whatsapp" : "sms", metadata: { name: customerData.name, email: customerData.email, plan: customerData.plan, signupDate: new Date().toISOString(), }, }); console.log(`Contact updated: ${contact.id}`); return contact; } // Usage processNewCustomer("+14155551234", { name: "John Doe", email: "john@example.com", plan: "premium", }); ``` ## Next Steps > [card] [Smart Routing](/guides/sending-messages/smart-routing) > > Learn how defaultChannel affects message routing > [card] [Broadcasts](/guides/broadcasts/overview) > > Send messages to many contacts at once > [card] [Phone Lookup](/tools/number-lookup) > > Validate and lookup phone numbers > [card] [Webhooks](/guides/receiving-messages/webhooks) > > Receive updates when contacts reply ## API Reference Manage contacts programmatically: * [List contacts](/api-reference/list-contacts) — paginated list, with `search` over name, phone and email * [Get a contact](/api-reference/get-contact) — retrieve a contact by ID * [Get contact by phone](/api-reference/get-contact-by-phone-number) — lookup a contact by phone number * [Update a contact](/api-reference/update-contact) — set the name, default channel, or metadata * [Delete a contact](/api-reference/delete-contact) — permanently remove a contact # Email Health & Deliverability Source: https://docs.zavu.dev/guides/email-health/overview Understand how Zavu monitors your email bounce rate and protects your sender reputation Zavu monitors your email deliverability to protect your sender reputation and ensure high delivery rates. This guide explains how the system works and what happens when bounce rates exceed safe thresholds. ## Why Email Health Matters Email providers like Gmail, Yahoo, and corporate mail servers track sender reputation. A high bounce rate indicates poor list hygiene and can result in: * Your emails being marked as spam * Reduced inbox placement * Complete blocking by email providers * Amazon SES account suspension Zavu automatically monitors and enforces bounce rate limits to protect both your reputation and our shared sending infrastructure. ## Bounce Rate Thresholds We track your bounce rate over a rolling 7-day window: | Bounce Rate | Status | Impact | | - | - | - | | **\< 5%** | Healthy | No restrictions | | **5% - 8%** | Warning | You'll receive a notification to review your email list | | **8% - 10%** | Email Paused | Email sending is paused; SMS and WhatsApp continue working | | **> 10%** | Suspended | All API access is suspended until resolved | > [!WARNING] > > Amazon SES has a strict **0.1% complaint rate** threshold. If recipients mark your emails as spam, your account may be suspended immediately. ## Types of Bounces ### Hard Bounces Permanent delivery failures that immediately add the email to the suppression list: * **Invalid address**: Email address doesn't exist * **Domain not found**: Domain has no mail servers * **Rejected**: Server permanently rejected the message ### Soft Bounces Temporary delivery failures that are tracked: * **Mailbox full**: Recipient's mailbox is full * **Server temporarily unavailable**: Try again later * **Message too large**: Email exceeded size limits > [!NOTE] > > After **3 soft bounces** to the same address within 7 days, the email is automatically added to the suppression list for 7 days. ## Email Suppression List The suppression list prevents sending to addresses that have bounced or complained. This protects your bounce rate from known-bad addresses. When you try to send to a suppressed email: ```json theme={null} { "code": "bad_request", "message": "Cannot send to user@example.com: Email is suppressed (hard_bounce)" } ``` ### Suppression Reasons | Reason | Duration | Description | | - | - | - | | `hard_bounce` | Permanent | Email address is invalid | | `soft_bounce` | 7 days | Temporary issue, will retry after expiration | | `complaint` | Permanent | Recipient marked email as spam | | `unsubscribe` | Permanent | Recipient unsubscribed | | `manual` | Permanent | Manually added by you | ## What Happens When You're Suspended ### Warning Level (5-8%) * Email notification sent to account owner * Yellow banner appears in dashboard * No sending restrictions yet * **Action required**: Review and clean your email list ### Email Paused Level (8-10%) * Email sending is blocked * SMS and WhatsApp continue working * Orange banner appears in dashboard * **Action required**: Remove invalid emails and contact support ### Suspended Level (>10%) * All API access is blocked (400 errors) * Red banner appears in dashboard * **Action required**: Contact support immediately ## Checking Your Email Health ### Dashboard Visit the **Email Health** page in your dashboard to see: * Current bounce rate and status * Suppressed email addresses * Suspension history ### API Check if an email is suppressed before sending: ```typescript TypeScript theme={null} // The API automatically checks suppression // You'll get an error if the email is suppressed try { await zavu.messages.send({ to: "user@example.com", channel: "email", subject: "Hello", text: "Your message" }); } catch (error) { if (error.code === "bad_request" && error.message.includes("suppressed")) { console.log("Email is suppressed, skipping"); } } ``` ```python Python theme={null} # The API automatically checks suppression # You'll get an error if the email is suppressed try: zavu.messages.send( to="user@example.com", channel="email", subject="Hello", text="Your message" ) except ZavuError as e: if "suppressed" in str(e): print("Email is suppressed, skipping") ``` ```ruby Ruby theme={null} # The API automatically checks suppression # You'll get an error if the email is suppressed begin client.messages.send_( to: "user@example.com", channel: "email", subject: "Hello", text: "Your message" ) rescue Zavudev::Errors::Error => e if e.message.include?("suppressed") puts "Email is suppressed, skipping" end end ``` ```go Go theme={null} // The API automatically checks suppression // You'll get an error if the email is suppressed _, err := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("user@example.com"), Channel: zavudev.String("email"), Subject: zavudev.String("Hello"), Text: zavudev.String("Your message"), }) if err != nil { if strings.Contains(err.Error(), "suppressed") { fmt.Println("Email is suppressed, skipping") } } ``` ```php PHP theme={null} // The API automatically checks suppression // You'll get an error if the email is suppressed try { $client->messages->send([ 'to' => 'user@example.com', 'channel' => 'email', 'subject' => 'Hello', 'text' => 'Your message', ]); } catch (Zavudev\Error $e) { if (str_contains($e->getMessage(), 'suppressed')) { echo "Email is suppressed, skipping\n"; } } ``` ## How to Maintain Good Email Health ### 1. Verify Email Addresses Use double opt-in to ensure email addresses are valid: ```typescript theme={null} // Step 1: User signs up await zavu.messages.send({ to: userEmail, channel: "email", subject: "Verify your email", text: `Click here to verify: ${verificationLink}` }); // Step 2: Only add to your list after they click ``` ### 2. Clean Your List Regularly Remove emails that: * Haven't opened emails in 6+ months * Have bounced previously * Have unsubscribed ### 3. Use Engagement-Based Sending Send more frequently to engaged users, less to inactive ones. ### 4. Monitor Bounce Rates Check your Email Health dashboard weekly. Address issues before reaching thresholds. ### 5. Handle Bounces Gracefully ```typescript theme={null} // When a bounce webhook arrives, update your database app.post("/webhooks/zavu", (req, res) => { const { type, data } = req.body; if (type === "message.failed" && data.errorCode === "bounce") { // Mark user as having invalid email await db.users.update({ where: { email: data.to }, data: { emailInvalid: true } }); } }); ``` ## Resolving a Suspension If your account is suspended: 1. **Review the Email Health dashboard** to understand the cause 2. **Export and clean your email list** - remove all bounced addresses 3. **Contact support** at [support@zavu.dev](mailto:support@zavu.dev) with: * Your project ID * Steps you've taken to clean your list * Your plan to prevent future issues > [!NOTE] > > Suspensions are typically resolved within 24-48 hours after you've demonstrated list cleanup. ## Best Practices Summary > [card] Do > > * Use double opt-in > * Clean lists regularly > * Monitor bounce rates weekly > * Handle bounces in webhooks > * Remove unsubscribes immediately > [card] Don't > > * Buy or rent email lists > * Send to old, unverified lists > * Ignore bounce notifications > * Re-add bounced emails > * Send without permission ## Related * [Sending Emails](/guides/sending-messages/email) - How to send emails via the API * [Webhooks](/guides/receiving-messages/webhooks) - Handle bounce and complaint events # Email Setup Guide Source: https://docs.zavu.dev/guides/email/setup Add email as a channel on your sender profile with a domain you own Email is configured as a **channel** on a sender profile, sending from a domain you own and verify. No identity verification is needed to send email: a verified domain and your plan's quota are all it takes. ## Prerequisites Before setting up email, you need a **sender profile**. If you don't have one yet: 1. Go to **Sender Profiles** in the dashboard 2. Click **New Sender** and give it a name 3. Once created, click on the sender to open its settings ![Sender profiles list](https://mintcdn.com/crubingllc/aSUs3--vtRO0jg_p/images/guides/email/sender-profiles-list.png?fit=max\&auto=format\&n=aSUs3--vtRO0jg_p\&q=85\&s=dc5bc9f8ed67fb1d3e3bfa231d3b234d) *** ## Sandbox email is no longer available Sandbox domains (`*.sandbox.zavu.dev`) have been retired. Every email channel sends from a domain you own. A sender still configured with a sandbox domain must be reconfigured with a verified domain to keep sending; the steps below cover it. *** ## Custom Domain Email Add your own domain to send from addresses like `noreply@yourcompany.com`. **1. Add your domain** Domains are managed under **Accounts**, so one domain can serve several senders. Add your domain there (e.g., `yourcompany.com`). The Email channel dialog links to it with **Go to Accounts to add a domain** when the project has none yet. **2. Configure DKIM records** Open the domain's **DNS panel**. It lists the DKIM CNAME records to add at your DNS provider, formatted for that provider. Copy each record and add it: ![DKIM records table](https://mintcdn.com/crubingllc/aSUs3--vtRO0jg_p/images/guides/email/dkim-records-table.png?fit=max\&auto=format\&n=aSUs3--vtRO0jg_p\&q=85\&s=4252477f754abe1bb2e70e14f5365456) The records look like: ``` selector1._domainkey.yourcompany.com CNAME selector1.dkim.zavu.dev selector2._domainkey.yourcompany.com CNAME selector2.dkim.zavu.dev selector3._domainkey.yourcompany.com CNAME selector3.dkim.zavu.dev ``` > [!TIP] > > DNS propagation typically takes a few minutes, but can take up to 72 hours. Use the copy buttons next to each record to avoid typos. **3. Wait for verification** Verification runs automatically in the background once the records propagate; you can close the dialog and Zavu notifies you when it is done. Once verified, the domain status changes to **Verified** and the from address fields appear. **4. Select the domain on your sender** Go to **Sender Profiles** > your sender > **Channels** tab > click the **Email** card, and pick the domain under **Email Domain**. **5. Configure from address** Set your: * **From Email Address**: e.g., `noreply` * **From Name**: e.g., `Your Company` * **Reply-To Email** (optional): e.g., `support@yourcompany.com` **6. Save** Click **Save** to enable the email channel with your custom domain. > [!NOTE] > > No identity verification is required to send email, single sends and broadcasts alike. What applies is your plan's email quota (100 a day and 3,000 a month on Free), the reputation of your domain, and the automated review every email passes through before it leaves. See [Sending limits](/concepts/sending-limits). ### Send a Test Email ```typescript TypeScript theme={null} import Zavu from "@zavudev/sdk"; const zavu = new Zavu({ apiKey: process.env.ZAVUDEV_API_KEY }); const result = await zavu.messages.send({ to: "recipient@example.com", channel: "email", subject: "Test from Zavu", text: "If you see this, your email channel is working!", }); console.log("Message ID:", result.message.id); console.log("Status:", result.message.status); ``` ```python Python theme={null} from zavudev import Zavu zavu = Zavu(api_key="your-api-key") result = zavu.messages.send( to="recipient@example.com", channel="email", subject="Test from Zavu", text="If you see this, your email channel is working!", ) print(f"Message ID: {result.message.id}") print(f"Status: {result.message.status}") ``` ```ruby Ruby theme={null} require "zavudev" client = Zavudev::Client.new(api_key: ENV["ZAVUDEV_API_KEY"]) result = client.messages.send_( to: "recipient@example.com", channel: "email", subject: "Test from Zavu", text: "If you see this, your email channel is working!" ) puts "Message ID: #{result.message.id}" puts "Status: #{result.message.status}" ``` ```go Go theme={null} result, _ := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("recipient@example.com"), Channel: zavudev.String("email"), Subject: zavudev.String("Test from Zavu"), Text: zavudev.String("If you see this, your email channel is working!"), }) fmt.Println("Message ID:", result.Message.ID) fmt.Println("Status:", result.Message.Status) ``` ```php PHP theme={null} messages->send([ 'to' => 'recipient@example.com', 'channel' => 'email', 'subject' => 'Test from Zavu', 'text' => 'If you see this, your email channel is working!', ]); echo "Message ID: " . $result->message->id . "\n"; echo "Status: " . $result->message->status . "\n"; ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "recipient@example.com", "channel": "email", "subject": "Test from Zavu", "text": "If you see this, your email channel is working!" }' ``` > [!TIP] > > If you have multiple senders, use the `Zavu-Sender` header to specify which sender to use. Otherwise, the default sender is used. ### Create an email sender via the API Once a domain is verified (dashboard or above), you can create an email sender programmatically — no phone number required. Set `emailAddress` to a from-address at your verified domain: ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/senders \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Transactional", "emailAddress": "noreply@yourdomain.com", "emailFromName": "Your Company", "setAsDefault": true }' ``` ```typescript TypeScript theme={null} const sender = await zavu.senders.create({ name: "Transactional", emailAddress: "noreply@yourdomain.com", emailFromName: "Your Company", setAsDefault: true, }); ``` ```python Python theme={null} sender = zavu.senders.create( name="Transactional", email_address="noreply@yourdomain.com", email_from_name="Your Company", set_as_default=True, ) ``` The address's domain must already be verified in your project. `emailDomainId` is optional — Zavu resolves it from the address. To attach an email address to an **existing** sender instead, `PATCH /v1/senders/{senderId}` with the same `emailAddress` field. Then send from it — as the default sender, or by passing its ID in the `Zavu-Sender` header: ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "user@example.com", "channel": "email", "subject": "Hi", "text": "Hello!" }' ``` > [!NOTE] > > Passing `from` in the send body does **not** create or select a sender — the from-address comes from the sender's configured `emailAddress`. Create the email sender first (above), then it sends as the default or via `Zavu-Sender`. *** ## Receiving Emails Zavu can receive inbound emails and deliver them to your application via webhooks. Your domain needs an MX record for it. ### Add the MX record **1. Open the Email channel dialog** Go to your sender > **Channels** > **Email** card. **2. Find the MX record section** Under **Enable Email Receiving**, Zavu shows the MX record to add to your DNS: ``` yourcompany.com MX 10 inbound.zavu.dev ``` **3. Verify MX record** After adding the record, click **Verify MX**. Once verified, the toggle becomes available. **4. Enable receiving** Toggle **Enable Email Receiving** on and save. ### Set Up Webhook for Inbound Emails To receive inbound emails in your application, configure a webhook on your sender: 1. Go to your sender > **Webhooks** tab 2. Add your webhook URL and subscribe to the `message.inbound` event Or via the API: ```typescript TypeScript theme={null} await zavu.senders.update({ senderId: "sender_abc123", webhookUrl: "https://api.yourcompany.com/webhooks/zavu", webhookEvents: ["message.inbound"], }); ``` ```python Python theme={null} zavu.senders.update( sender_id="sender_abc123", webhook_url="https://api.yourcompany.com/webhooks/zavu", webhook_events=["message.inbound"], ) ``` ```ruby Ruby theme={null} client.senders.update( sender_id: "sender_abc123", webhook_url: "https://api.yourcompany.com/webhooks/zavu", webhook_events: ["message.inbound"] ) ``` ```go Go theme={null} client.Senders.Update(context.TODO(), "sender_abc123", zavudev.SenderUpdateParams{ WebhookURL: zavudev.String("https://api.yourcompany.com/webhooks/zavu"), WebhookEvents: []string{"message.inbound"}, }) ``` ```php PHP theme={null} senders->update('sender_abc123', [ 'webhookUrl' => 'https://api.yourcompany.com/webhooks/zavu', 'webhookEvents' => ['message.inbound'], ]); ``` ```bash cURL theme={null} curl -X PATCH https://api.zavu.dev/v1/senders/sender_abc123 \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "webhookUrl": "https://api.yourcompany.com/webhooks/zavu", "webhookEvents": ["message.inbound"] }' ``` When an email is received, Zavu sends a `message.inbound` event: ```json theme={null} { "id": "evt_1736850000000_abc123", "type": "message.inbound", "timestamp": 1736850000000, "senderId": "sender_abc123", "projectId": "proj_xyz789", "data": { "messageId": "msg_def456", "to": "support@yourcompany.com", "from": "customer@gmail.com", "channel": "email", "status": "received", "subject": "Question about my order", "text": "Hi, I have a question about order #12345..." } } ``` > [!TIP] > > See the [Webhooks guide](/guides/receiving-messages/webhooks) for details on signature verification and security. ### Catch-all: receive every address at your domain By default a sender only receives email sent to its exact address (e.g. `support@yourcompany.com`). Enable **Catch-all** to receive email addressed to **any** local part at the domain — `sales@`, `billing@`, `hello@`, or an address that doesn't exist as its own sender — all delivered through this one sender's webhook. **1. Enable receiving first** Catch-all builds on receiving, so **Enable Email Receiving** must be on (the domain's MX record must be verified). See the steps above. **2. Toggle Catch-all** In the sender's **Channels > Email** dialog, turn on **Catch-all address** and save. The toggle only appears once receiving is enabled. Every catch-all message fires the same `message.inbound` event. Route sub-addresses yourself using `data.to`, which is always the **original recipient**: ```json theme={null} { "type": "message.inbound", "data": { "to": "sales@yourcompany.com", "from": "customer@gmail.com", "channel": "email", "subject": "Interested in a demo", "text": "..." } } ``` You can also toggle catch-all from the API with `emailCatchAllEnabled` on the sender: ```bash cURL theme={null} curl -X PATCH https://api.zavu.dev/v1/senders/sender_abc123 \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "emailReceivingEnabled": true, "emailCatchAllEnabled": true }' ``` > [!NOTE] > > Catch-all is per sender. Only one sender per domain should have it enabled — it owns all otherwise-unmatched addresses for that domain. Addresses that exactly match another sender still route to that sender. `emailCatchAllEnabled` is ignored unless `emailReceivingEnabled` is also on. *** ## Testing Your Setup ### Verify Sending 1. Send a test email using the code examples above 2. Check the message status in **Messages** in the dashboard 3. Verify the email arrived in the recipient's inbox ### Verify Receiving 1. Send an email to your configured address (e.g., `support@yourcompany.com`) 2. Check your webhook endpoint received the `message.inbound` event 3. Verify the inbound message appears in the dashboard under **Messages** ### Common Issues | Issue | Cause | Solution | | - | - | - | | Email not delivered | Sender has no email channel configured | Add email channel on your sender profile | | Domain stuck on "Pending" | DNS records not propagated | Wait up to 72 hours; verification runs automatically, and the **DNS panel** shows which records are still missing | | `daily_limit_exceeded` error | The plan's daily email quota is spent (100 a day on Free) | Wait for 00:00 UTC or upgrade the plan | | Emails going to spam | Missing SPF or DMARC records, or a new domain | Publish the recommended SPF and DMARC records (`GET /v1/email-domains/{domainId}` lists them) and ramp volume up gradually | | Inbound not working | MX record missing or not verified | Add MX record and click **Verify MX** | | Only the exact address receives mail | Catch-all not enabled | Turn on **Catch-all address** in the Email channel dialog | | Can't toggle receiving | MX record not verified yet | Verify MX record first | ## Next Steps * [Sending Emails](/guides/sending-messages/email) - API reference for email messages * [Webhooks](/guides/receiving-messages/webhooks) - Set up webhooks for delivery events * [Email Health](/guides/email-health/overview) - Monitor bounce rates and deliverability # eve Source: https://docs.zavu.dev/guides/frameworks/eve Run an agent written with eve, Vercel's agent framework, without changing a file. ## The eve layout [eve](https://eve.dev) keeps an agent on the filesystem: a markdown file for the instructions, an optional config file, and one file per tool. ``` my-agent/ agent/ instructions.md <- required. This is what identifies the project. agent.ts <- optional. `model` and `name` are read. tools/ get_weather.ts <- one tool per file, default-exporting defineTool(...) package.json ``` `agent/instructions.md` is the whole detection rule. Zavu compiles the project at deploy time into the same registry a native project declares directly, so what runs afterwards is an ordinary Zavu agent. **No file of yours is edited.** ## What carries over | In eve | On Zavu | | - | - | | `agent/instructions.md` | the agent's prompt | | `model` in `agent.ts` | the agent's model | | `name` in `agent.ts` | the agent's name, over the one in `package.json` | | `agent/tools/*.ts` | one Zavu tool each, Zod schemas converted to JSON Schema | | `execute(input, ctx)` | the tool's handler | A file under `agent/tools/` counts as a tool only when it default-exports `defineTool(...)`. One that does not is named in the report before the deploy starts, so the tool count is always the number of tools actually created. ## What does not Every eve capability without an equivalent is listed explicitly before the deploy runs, one line each — nothing is dropped in silence. Today that includes channels, schedules, subagents, the sandbox, connections, evals, and approval gates. A `voice` block in `agent.ts` is not translated either: the agent deploys as text only. To give it a voice, configure it on the Zavu side. Every key `agent.ts` declares that Zavu does not read is reported by name, including keys we have never heard of, so a config that quietly does nothing is visible rather than assumed. ## Deploying ```bash From your machine theme={null} npx zavudev deploy ``` ```bash From a repository theme={null} npx zavudev import acme/my-eve-agent ``` ```bash On every push theme={null} npx zavudev fn git link acme/my-eve-agent ``` Compiling locally needs `esbuild` in the project (`npm install -D esbuild`); `zavudev import` installs it for you, and deploying through a GitHub link needs nothing at all, because that build happens on Zavu. The agent reads `SENDER_ID` at runtime, or takes `--sender` at deploy time: ```bash theme={null} npx zavudev fn secrets set SENDER_ID ``` ## One thing worth knowing An eve project declares no HTTP handler and no event handler, only an agent and its tools. Its public URL answers 404, and an [event trigger](/guides/functions/triggers) on it invokes a function with nothing to run. That is expected: the agent replies because it is attached to a sender, and its tools run when the agent calls them. For the deeper walkthrough, see [Deploy an eve project](/guides/functions/deploy-eve-project). # Frameworks Source: https://docs.zavu.dev/guides/frameworks/overview Deploy an agent written in the framework you already use. Zavu adapts it, without changes to your project. ## Bring the framework you already use Zavu runs agents written in more than one authoring format. You keep your project as it is; Zavu adapts it at deploy time and the result is a first-class Zavu agent, with the same inbox, the same executions log, and the same billing as one written directly against `@zavudev/functions`. The principle behind every adapter: **standard authoring format, Zavu runtime.** An adapter never asks you to move a file or rename a folder, and it never pretends to support something it cannot run. Anything in your project that has no equivalent on Zavu is listed explicitly before the deploy starts. ## Supported today | Framework | Recognized by | Guide | | - | - | - | | **Zavu** | `index.ts` at the root | [Quickstart](/guides/functions/quickstart) | | **eve** | `agent/instructions.md` | [Deploy an eve project](/guides/functions/deploy-eve-project) | Those two files are exactly what Zavu looks for, whether you deploy from the CLI, from the dashboard, or on a push to GitHub. A repository with neither is refused before anything is created, rather than failing halfway through a build. ## Monorepos The check runs at the root of the repository. If your agent lives in a subfolder, point Zavu at it with a root directory: ```bash CLI theme={null} npx zavudev import acme/platform --root apps/agent ``` ```bash Existing function theme={null} npx zavudev fn git link acme/platform # then set the root directory from the function's Git tab ``` In the dashboard, the import dialog offers a **root directory** field as soon as it cannot find a project at the root. ## What every adapter guarantees * **Your files are not edited.** The adaptation happens at deploy time. * **Gaps are reported, never silently dropped.** Every capability your framework declares that Zavu cannot run is named before the deploy, one line each. * **The same deploy pipeline.** Whatever the format, what runs afterwards is a Zavu function: same secrets, same logs, same rollback, same deploy-on-push. ## Your framework is not here yet The list grows. An adapter is a compiler from a project layout to the Zavu registry (`defineAgent` and `defineTool`), plus an honest compatibility report, so adding one is a contained piece of work rather than a platform change. If you want one for the framework you use, open an issue on [github.com/zavudev](https://github.com/zavudev) describing the layout, or write to support with a link to a repository we can look at. A public sample project is the single most useful thing you can send. In the meantime, an agent in any language can still reach Zavu through the [REST API](/api-reference) or an [SDK](/sdks) — what the adapters add is deploying and hosting the agent itself. # Zavu Source: https://docs.zavu.dev/guides/frameworks/zavu The native layout: one index.ts that declares your agent and its tools. ## The native layout A Zavu project is a single entry file that declares what it is. Nothing is registered by convention or discovered by scanning folders: `defineAgent` and `defineTool` run at module load, the deploy reads the declarations, and the live agent is reconciled to match. ``` my-agent/ index.ts <- required. This is what identifies the project. package.json <- optional. Its dependencies are installed for you. ``` `index.ts` at the root is the whole detection rule. If it is there, Zavu treats the repository as a native project. ## A minimal project ```ts index.ts theme={null} import { defineAgent, defineTool } from "@zavudev/functions" defineAgent({ senderId: process.env.SENDER_ID!, name: "Support", provider: "zavu", model: "gpt-4o-mini", prompt: `You are a support agent. When someone asks about an order, call get_order_status and answer with what it returns. Never invent a status or a date.`, }) defineTool({ name: "get_order_status", description: "Look up the delivery status of one order by its id. Call this whenever a customer asks where their order is.", parameters: { type: "object", properties: { orderId: { type: "string" } }, required: ["orderId"], }, handler: async (args) => ({ orderId: args.orderId, status: "in_transit" }), }) ``` `npx zavudev fn init` scaffolds exactly this. ## What each part does **`defineAgent`** is the agent's configuration: model, prompt, channels, and optionally a `voice` block. Read `SENDER_ID` from the environment rather than hard-coding a sender, so the same repository can be deployed against a different sender without an edit. An agent declared without one is created anyway; attach a sender afterwards from its page and the attachment survives every redeploy. **`defineTool`** is a capability the model can call, with `parameters` as plain JSON Schema. The handler runs inside your function. Tools are offered on every channel, including plain text, and the model may chain up to five tool rounds in a single reply. **`defineFunction`** is separate, and optional. It handles raw events and HTTP requests, and it is what an [event trigger](/guides/functions/triggers) invokes. A project that declares only an agent and its tools has no handler, so a trigger on it wakes the function and runs nothing — the agent replies on its own, because it is attached to a sender. ## Deploying ```bash Once theme={null} npx zavudev deploy ``` ```bash On every push theme={null} npx zavudev fn git link acme/my-agent ``` Both paths run the same pipeline. See [Deploy from GitHub](/guides/functions/deploy-from-github) for the push-based one, and [Frameworks](/guides/frameworks/overview) for the other layouts Zavu accepts. # Advanced patterns Source: https://docs.zavu.dev/guides/functions/advanced-patterns Production-grade techniques: composition, state, observability, testing. ## Advanced patterns You've shipped your first Function. Now what holds up at scale. ## Persistent state Functions are stateless. Cold starts wipe in-process variables. Pick one: ### Convex tables (recommended for Zavu users) Tightly integrated. The auto-provisioned `ZAVU_API_KEY` doesn't grant arbitrary table access — for that, create a project API key with broader scopes and inject it as a secret. Or use Convex deployments outside our managed scope. ### Postgres (managed: PlanetScale, Neon, Supabase) ```ts theme={null} import postgres from "postgres" let sql: ReturnType | null = null function getDb() { if (!sql) sql = postgres(process.env.DATABASE_URL!, { ssl: "require" }) return sql } defineTool({ name: "save_lead", handler: async (args, ctx) => { const db = getDb() await db`INSERT INTO leads (phone, email, source) VALUES (${ctx.contactPhone}, ${args.email}, 'whatsapp')` return { saved: true } }, }) ``` The `let sql = null` pattern lets the same connection survive across warm invocations — saves the connection handshake (\~50ms) on subsequent calls. ### Redis (Upstash for serverless-friendly) For rate-limiting, deduplication, and short-lived state: ```ts theme={null} import { Redis } from "@upstash/redis" const redis = new Redis({ url: process.env.UPSTASH_URL!, token: process.env.UPSTASH_TOKEN!, }) defineTool({ name: "send_otp", handler: async (args, ctx) => { const rateLimited = await redis.set( `otp:${ctx.contactPhone}`, "1", { ex: 60, nx: true } ) if (rateLimited !== "OK") { return { error: "rate_limited", message: "Espera 1 minuto antes de pedir otro código." } } // ... }, }) ``` ## Composing multiple functions One project can have many functions. Use this for separation of concerns: | Function | Job | | - | - | | `support-agent` | Reactive: handles WhatsApp inbound, has agent + tools. | | `daily-digest` | Scheduled: sends daily metric digest. Triggered by cron. | | `cart-recovery` | Triggered: fires on `cart.abandoned` event (custom). | | `dlq-watcher` | Triggered: fires on `message.failed`, retries with fallback. | Functions don't directly call each other today — they communicate via Zavu events (triggers) or your own database / queue. ## Observability ### Structured logs Use the framework's `ctx.log` so the dashboard's logs panel can highlight your output among the runtime's ceremony lines: ```ts theme={null} handler: async (args, ctx) => { ctx.log("processing", { customer: ctx.contactPhone, args }) try { const result = await doStuff(args) ctx.log("ok", { ms: Date.now() - t0 }) return result } catch (err) { ctx.log("error", { err: err.message, stack: err.stack }) throw err } } ``` ### Metrics → external sinks Send important business events to a metrics service: ```ts theme={null} async function metric(name: string, props: Record) { await fetch("https://api.posthog.com/capture", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ api_key: process.env.POSTHOG_KEY, event: name, properties: props, distinct_id: props.customerPhone ?? "anonymous", }), }) } defineTool({ name: "create_reservation", handler: async (args, ctx) => { const result = await reserve(args) await metric("reservation_created", { customerPhone: ctx.contactPhone, partySize: args.partySize, time: args.time, }) return result }, }) ``` `fetch` runs in parallel — don't `await` it if you don't care about delivery guarantees. Use a fire-and-forget: ```ts theme={null} ctx.log("queued metric", { name: "reservation_created" }) metric("reservation_created", { /* ... */ }).catch(() => {}) return result ``` ## Error budgets and retries The agent retries failed tool calls up to 2 times (LLM's choice — it sees the error message and may try again). Beyond that, the LLM gives up and tells the customer. For tools that touch unreliable systems (3rd-party APIs), add your own retry-with-backoff: ```ts theme={null} async function withRetry(fn: () => Promise, tries = 3): Promise { for (let i = 0; i < tries; i++) { try { return await fn() } catch (err) { if (i === tries - 1) throw err await new Promise((r) => setTimeout(r, 2 ** i * 200)) } } throw new Error("unreachable") } defineTool({ name: "lookup_order", handler: async (args) => { const res = await withRetry(() => fetch(`https://flaky-pos.com/orders/${args.orderId}`).then(r => r.ok ? r.json() : Promise.reject()) ) return res }, }) ``` ## Testing ### Local invoke ```sh theme={null} npx zavudev fn invoke --event message.inbound \ --data '{"from":"+14155551234","text":"I need a pizza","channel":"whatsapp"}' ``` Runs your default handler with a synthetic event. Useful for trigger-based functions and the `defineFunction` fallback path. **Doesn't simulate LLM tool calls** — for that, deploy + use the real WhatsApp sender. ### Unit tests for handlers Tool handlers are plain functions. Extract them, test them: ```ts theme={null} // shared/menu.ts export async function viewMenuImpl(args: { filter?: string }) { const items = args.filter === "vegan" ? MENU.filter(m => m.vegan) : MENU return { menu: items, count: items.length } } // index.ts import { viewMenuImpl } from "./shared/menu" defineTool({ name: "view_menu", description: "...", parameters: { /* ... */ }, handler: (args) => viewMenuImpl(args), }) // shared/menu.test.ts import { viewMenuImpl } from "./menu" test("vegan filter only returns vegan items", async () => { const result = await viewMenuImpl({ filter: "vegan" }) expect(result.menu.every(m => m.vegan)).toBe(true) }) ``` Use `bun test` or `vitest` locally — they don't need to ship with the function. ### Integration with the LLM To test how the LLM ACTUALLY picks tools, you need a live agent. The fastest loop: ```sh theme={null} npx zavudev fn push # save draft, fast # (run prompts manually in dashboard's Agent Playground) # OR npx zavudev deploy # real deploy npx zavudev messages send --to my-test-phone --text "what's vegan?" --sender $SENDER_ID --channel whatsapp npx zavudev fn logs --tail # watch tool calls ``` ## Multi-agent on one sender Not directly supported — one agent per sender. But you can simulate it with flows OR by having a "router" tool: ```ts theme={null} defineTool({ name: "switch_persona", description: "Switch the conversation mode. Use when the user's intent clearly changes " + "(e.g. 'now I want to talk about returns instead').", parameters: { type: "object", properties: { mode: { type: "string", enum: ["shopping", "support", "feedback"] }, }, required: ["mode"], }, handler: async ({ mode }, ctx) => { await redis.set(`mode:${ctx.contactPhone}`, mode, { ex: 3600 }) return { mode, summary: `Modo cambiado a ${mode}.` } }, }) ``` Then prefix the system prompt with logic that reads `mode` from Redis at each turn. (You'd inject `mode` into the agent via custom contact metadata, which the agent reads automatically with `includeContactMetadata: true`.) In practice: one focused agent > one mega-agent juggling modes. Multiple senders / multiple functions is the canonical way. ## Cost optimization Per-conversation cost breaks down as: | Item | Order of magnitude | | - | - | | LLM tokens (gpt-5.6-luna, 3-turn convo) | \$0.0002–0.001 | | Function invocations (1 per tool call) | \$0.000001 each | | WhatsApp conversation | \$0.005–0.04 depending on country | | Your DB / API calls | varies | **LLM is rarely the bottleneck.** What kills budgets: * **Long prompts.** Every turn sends the full system prompt + last N messages. A 1000-token system prompt at 10 turns of history = 10k tokens per reply. Trim relentlessly. * **High `contextWindowMessages`.** Default 10 is overkill for transactional agents. Drop to 4-6 if your conversations are short. * **Re-reading large tool returns.** If a tool returns 500 items, the LLM re-reads them every turn. Trim server-side. ## Migration paths ### From dashboard-configured AI Agent → Function You already have an agent and tools created from the dashboard. To move them under code-managed control: 1. Write `defineAgent({...})` matching your existing config. 2. Write `defineTool({...})` for each existing tool, including the same `name`, `description`, `parameters`. 3. `npx zavudev deploy`. The reconciler sees existing rows with matching `(senderId, name)` and **takes ownership** — patches them to match your code AND marks them managed. The summary shows `+ ToolName (took over manual)` for each. From that point on, dashboard edits are blocked. Code is source of truth. ### From a custom webhook receiver → Function You have a Vercel function listening for Zavu webhooks. To move: 1. `npx zavudev fn init` and copy your handler into `defineFunction`. 2. Set up triggers via CLI instead of webhook URLs on senders: ```sh theme={null} npx zavudev fn triggers add --events message.inbound --senders any ``` 3. Disable the webhook on the sender (or leave it — both work in parallel during migration). Native triggers use Zavu-internal signed invocations (no HMAC), retry automatically, and have lower latency than a typical webhook through the internet. ## Limits to know | Resource | Hard limit | Soft (CLI rejects) | | - | - | - | | Function slug length | 23 chars | Auto-enforced | | Function name | 80 chars | — | | Memory | 1024 MB | — | | Timeout per invocation | 180 sec (default 30) | — | | Source size | 900 KB | — | | Bundled zip size | 6 MB | Triggers different code path | | Dependencies declared | 30 packages | — | | Secrets per function | 50 | — | | Secret value | 4 KB | — | | Tools per agent | unlimited in API; \~20 practical | LLM degrades past \~10 | For higher limits, contact support. ### What a call costs Functions are billed by memory and time, the same two things the infrastructure underneath charges for. One call is **128 MB running for one second**. A function with more memory, or one that takes longer, uses several; anything under a second counts as one, so a fast function costs exactly what it always did. | Function | Calls used per invocation | | - | - | | 128 MB, 200 ms | 1 | | 128 MB, 1 s | 1 | | 256 MB, 3 s | 6 | | 1024 MB, 10 s | 80 | 300,000 calls a month are included on every plan, then \$5 per million. Deploys, logs, rollbacks and triggers are never charged. ### Choosing a timeout The ceiling is 180 seconds and the default is 30, but the number that matters depends on how the function is invoked: * **Event and cron invocations are asynchronous.** Nothing is waiting on the response, so the timeout only bounds what one invocation can cost you. A slow nightly job is fine here. * **A tool called during a conversation is synchronous.** The agent's reply waits for it, and so does the person who wrote the message. Keep these well under the ceiling: a tool that takes 60 seconds is a conversation that looks broken. * **HTTP-exposed functions** are additionally bounded by the platform's own response limit, which is lower than 180 seconds and is not something a function setting can raise. Raise the timeout when the work genuinely takes longer, not to paper over a slow dependency in a live path. ## Next > [card] [Restaurant example](/guides/functions/examples/restaurant) > > Complete booking agent with persistence. > [card] [Runtime versions](/guides/functions/runtime) > > Pinning, upgrades, security patches. # Debugging Source: https://docs.zavu.dev/guides/functions/debugging Diagnose why your agent isn't responding, tool calls are failing, or deploys are stuck — in order from most-likely cause to least. ## Debugging Functions When your function isn't behaving, the issue is usually in one of these layers: ``` ┌─────────────────────────────────────────────────┐ │ 1. Inbound message (does it arrive?) ├─────────────────────────────────────────────────┤ │ 2. Agent dispatch (does the agent run?) ├─────────────────────────────────────────────────┤ │ 3. LLM call (does the LLM respond?) ├─────────────────────────────────────────────────┤ │ 4. Tool invocation (does the tool get called?) ├─────────────────────────────────────────────────┤ │ 5. Tool handler (does YOUR code work?) └─────────────────────────────────────────────────┘ ``` Walk the layers top-to-bottom. The first one that fails is the one to fix. > [!TIP] > > If you use Claude Code / Cursor / Copilot, install [Zavu's Coding Agent Skills](/tools/coding-agent-skills) > and ask your assistant "why isn't my agent responding?" — it'll run the > diagnostic commands below in order and interpret the output. > > ```sh theme={null} > npx skills add zavudev/zavu-skills > ``` ## 1. Did the inbound message arrive? Open the dashboard → **Senders** → your sender → **Conversations**. * **No message visible**: the inbound never reached Zavu. Check your sender's webhook (WhatsApp, Telegram) is wired up. The dashboard shows webhook config and recent delivery attempts. * **Message visible, no reply**: inbound is fine, problem is downstream. Go to step 2. ## 2. Did the agent run? ```sh theme={null} npx zavudev agents executions list --sender ``` This is the most useful debug command. Three possible states: ### a) "No executions." The inbound arrived but the agent dispatcher didn't even try to invoke the agent. Causes: | Likely cause | Check | | - | - | | Wrong `SENDER_ID` secret on the function | `npx zavudev fn secrets list` — last 4 chars of the value must match the senderId you're testing | | Agent disabled | `npx zavudev agents get --sender ` → `enabled` must be `true` | | Channel filtered out | `triggerOnChannels` doesn't include the inbound's channel. Use `["*"]` or include the specific one (e.g. `"telegram"`) | | Message type filtered out | `triggerOnMessageTypes` excludes the type (e.g. you sent an image and only `"text"` is allowed) | | Empty message text | The dispatcher skips messages with no text. Inbound types like reactions or system events don't trigger agents | ### b) `status: error` The agent ran but something blew up. Fetch the full detail: ```sh theme={null} npx zavudev agents executions get --sender ``` You'll see `errorMessage` and `errorCode`. Common error categories: | `errorCode` | Meaning | Fix | | - | - | - | | `balance_insufficient` | Provider `zavu` ran out of AI gateway balance | Top up balance in dashboard, or switch to BYOK by setting `provider` + `apiKey` in `defineAgent` | | `rate_limited` | Hit the LLM provider's rate limit | Lower concurrency or wait | | `provider_error` | LLM provider rejected the call | Check the message text — sometimes prompts trigger safety filters. Also verify your `model` ID is valid for the provider | | `tool_not_found` | Agent tried to call a tool that doesn't exist | Tool name in `defineTool` doesn't match what the LLM is calling. Or the deploy didn't reconcile — run `npx zavudev deploy` again | | `tool_error` | A tool handler threw | Check the tool's source. The `errorMessage` includes the handler's exception text | ### c) `status: success` but no message sent back The agent succeeded but didn't reply. Look at `responseText` in the execution detail — if it's empty, the LLM returned nothing useful. Adjust your `systemPrompt` to encourage replies, or check that the model isn't returning just tool calls without a final response. ## 3. Is the LLM working? If executions are failing with `provider_error`: ```sh theme={null} npx zavudev agents executions get --sender --json ``` Look at the full `errorMessage`. Typical issues: * **Bad model ID**: e.g. `openai/gpt-4o-mini` works on `provider: "zavu"` (our gateway uses prefixed IDs), but the raw `gpt-4o-mini` works on `provider: "openai"` directly. * **Expired API key** (when using `provider: "openai"` etc.): rotate the key and update the agent's `apiKey`. * **Tool schema malformed**: rare, but if your `defineTool` parameters JSON Schema has a typo, the LLM's tool-calling step can fail. Validate the schema renders to valid JSON. ## 4. Did the tool get called? Once the agent runs successfully but you suspect the tool wasn't invoked, check the **function logs**: ```sh theme={null} npx zavudev fn logs --tail ``` Then send a message that should trigger the tool. You should see: ``` [ctx.log] ``` If you see nothing in the function logs but `agents executions` shows success, the LLM chose not to call the tool. Tighten the tool's `description` so the LLM understands when to call it. ## 5. Is the tool handler buggy? Once you confirm the tool is being called, isolate it locally: ```sh theme={null} npx zavudev fn invoke --tool view_menu --args '{"filter":"vegan"}' ``` This runs the handler with synthetic args and prints the result. Iterate locally without redeploying. For deeper debugging, add `ctx.log` calls inside the handler: ```ts theme={null} defineTool({ name: "lookup_order", // ... handler: async (args, ctx) => { ctx.log("lookup_order called with", args) const order = await fetchOrder(args.orderId) ctx.log("found order", { id: order?.id, status: order?.status }) return order }, }) ``` Then `npx zavudev deploy` and `npx zavudev fn logs --tail` while triggering the tool from a real conversation. The `[ctx.log]` lines appear in the live tail. ## Reconcile didn't pick up my new agent / tool When you run `npx zavudev deploy`, the platform reads your `defineAgent` / `defineTool` declarations from a `manifest probe` Lambda invocation, then reconciles the live agent + tools to match. If you see: ``` ! manifest probe: manifest probe threw: defineAgent: senderId is required. ``` …the issue is your code threw at module load time. Common causes: * **Missing required secret**: e.g. `process.env.SENDER_ID!` evaluates to `undefined` because you didn't set the secret yet. Fix: `npx zavudev fn secrets set SENDER_ID ` then `npx zavudev deploy`. * **Syntax error in your `index.ts`**: the bundler caught it earlier but some edge cases get through. Run `tsc --noEmit` locally before deploying. ## Local invocation cheat sheet | Goal | Command | | - | - | | Test a single tool handler | `npx zavudev fn invoke --tool --args '{...}'` | | Test an event handler (`defineFunction`) | `npx zavudev fn invoke --event message.inbound --data '{...}'` | | Tail live production logs | `npx zavudev fn logs --tail` | | Query recent agent runs | `npx zavudev agents executions list --sender ` | | Full detail of one run | `npx zavudev agents executions get --sender ` | | Inspect the agent config | `npx zavudev agents get --sender ` | | Inspect tools attached | `npx zavudev agents tools list --sender ` | | See what secrets are set | `npx zavudev fn secrets list` | ## When all else fails 1. Add `ctx.log("got here", { someState })` aggressively in your handler. 2. `npx zavudev deploy` and `npx zavudev fn logs --tail` simultaneously. 3. Trigger the flow from a real conversation. 4. Read the logs top-to-bottom — the first absence of expected output is the layer that's failing. 5. Open a ticket with the function ID, the `executionId` that failed, and the timestamp. Support can pull internal traces faster than guessing. # defineAgent Source: https://docs.zavu.dev/guides/functions/defining-agents Configure the LLM brain that drives your conversation. ## `defineAgent` `defineAgent` declares the AI agent that will run on a specific WhatsApp / SMS sender. Calling it inside your function source binds the agent to that sender on every `npx zavudev deploy`. ```ts theme={null} import { defineAgent } from "@zavudev/functions" defineAgent({ senderId: process.env.SENDER_ID!, name: "Bella", provider: "zavu", model: "openai/gpt-5.6-luna", prompt: "You are Bella, host of Bella Pizzeria…", channels: ["whatsapp"], }) ``` After `npx zavudev deploy`: * The agent is created (or updated) under that sender. * Tagged as **managed by this function** — the dashboard disables manual edits to prevent drift. * Marked `enabled: true` automatically. ## Required fields | Field | Type | Notes | | - | - | - | | `name` | string | Display name. Shown in `npx zavudev agents executions` and the dashboard. | | `provider` | `zavu \| openai \| anthropic \| google \| mistral` | LLM provider. | | `model` | string | Model id (depends on provider — see below). | | `prompt` | string | System prompt. `systemPrompt` is accepted as an alias. Multi-line OK (use backticks). | > [!WARNING] > > A declaration missing any of these four **throws**. `defineAgent` runs at module > load, so the error surfaces during the deploy's manifest probe: the function > itself still goes live, but nothing declared in your source syncs — agents and > tools stay exactly as they were. The CLI says so in the deploy output and exits > non-zero, so a script or CI job that only reads the exit code still catches it. > > Only those field names are read. `description` and `instructions` are not > fields — an agent whose prompt is written under one of them has no `prompt`, and > throws the same way. ## Optional fields | Field | Default | Description | | - | - | - | | `senderId` | — | The `_id` of an existing sender, usually `process.env.SENDER_ID`. Omit it to declare the agent standalone and connect senders afterwards; it answers nothing until one is connected. | | `channels` | `["*"]` | Which channels trigger the agent: `whatsapp`, `sms`, `sms_oneway`, `telegram`, `email`, `instagram`, `messenger`, `voice`, or `*` for every channel its sender carries. A channel the sender receives on but this list omits is dropped before the agent sees it, silently — prefer `*` unless you are deliberately excluding one. Unknown strings are accepted and match nothing, so a typo deploys green and never fires. | | `messageTypes` | `["text"]` | Message types: `text`, `image`, `audio`, … | | `groups` | `false` | Answer in group chats, on channels that carry them. Off means the agent answers one-to-one conversations only. See [Group chats](#group-chats). | | `apiKey` | — | Required when `provider !== "zavu"` (or pre-create a secret in the dashboard). | | `contextWindowMessages` | `10` | How many previous messages to include in each LLM call. | | `temperature` | provider default | 0–2. | | `maxTokens` | unbounded | Cap on response length. | | `sessionTimeoutMinutes` | `60` | After this idle gap, the agent starts a new conversation. | | `includeContactMetadata` | `true` | If true, contact name / metadata is included as context. | | `enabled` | `true` | Set to `false` to deploy the agent but keep it inactive. | | `voice` | — | Voice config. When present with `enabled: true`, the agent answers phone calls. See below. | ## Voice Add a `voice` block to make the agent answer calls. The LLM runs **co-located in the voice network** for the lowest latency, independent of the `model` used for text. `npx zavudev deploy` provisions everything; removing the block turns the agent back into text-only. ```ts theme={null} defineAgent({ senderId: process.env.SENDER_ID!, name: "Fermi", provider: "zavu", model: "openai/gpt-5.6-luna", // text model channels: ["voice", "whatsapp"], voice: { enabled: true, model: "openai/gpt-4o", // co-located voice model (optional; derived from `model` if omitted) greeting: "Hi, I'm Fermi. How can I help?", language: "en", // BCP-47; auto-detected if omitted interruptible: true, // caller can barge in maxCallDurationMinutes: 10, }, prompt: `…`, }) ``` > [!NOTE] > > Voice requires a phone number assigned to the sender. The fastest way to start is to pull a ready-made > voice agent: see [Factory agents](/guides/functions/factory-agents). ## Knowledge base Documents the agent answers from, declared with the agent. Retrieval runs before every reply. ```ts theme={null} defineAgent({ senderId: process.env.SENDER_ID!, name: "Ada", provider: "zavu", model: "openai/gpt-5.6-luna", prompt: "Answer from the store policies. If they do not cover it, say so.", knowledgeBase: { name: "Store policies", documents: [ { title: "Returns", content: `# Returns Unopened items: 30 days, full refund. Opened items: 14 days, only if faulty. Not returnable: gift cards, final sale.`, }, { title: "Shipping", content: `# Shipping Free over $50, 3 to 5 business days. Express is $12, next business day if ordered before 2pm. US and Canada only. No PO boxes.`, }, ], }, }) ``` The declaration is the source of truth, like everything else here. On deploy: * a document you added is created and embedded * a document whose `content` changed is re-embedded * a document you removed is deleted * a document that did not change is **left alone**, so a redeploy costs nothing The deploy tells you which happened: ``` Knowledge synced: + Returns ~ Shipping (re-embedded) - Old holiday hours ``` ### Titles are identity Documents are matched by `title`. Renaming one replaces it: the old document is deleted and a new one embedded. That is usually what you want, but it is a re-embed rather than an edit. ### Writing documents that retrieve Retrieval is similarity over chunks, not keyword search. * **Headings and short paragraphs** chunk cleanly. A wall of text produces chunks that each hold half an answer. * **Use the customer's words.** A document that says "return merchandise authorization" does not retrieve for "can I send it back". * **State facts.** "14 days" retrieves and answers. "within a reasonable period" retrieves and says nothing. * **One base per subject.** Mixing the refund policy with the API reference retrieves from both and dilutes the context. ### Check that it is actually being used ```sh theme={null} npx zavudev agents test --sender "$SENDER_ID" --message "can I return an opened item?" ``` The reply comes with `knowledge chunk(s)` used. **Zero on an agent that has documents means the answer was not grounded in them**, which is otherwise indistinguishable from a correct answer. ### The size limit is your source file Documents live inline, and the deploy caps source at about 900KB. A knowledge base larger than that belongs on the API: ```sh theme={null} npx zavudev agents knowledge-bases create --sender "$SENDER_ID" --name "Manuals" npx zavudev agents knowledge-bases documents add --sender "$SENDER_ID" --kb "$KB_ID" \ --title "Manual" --content-file ./manual.md ``` Both paths write to the same place. Declaring in code is better when the documents belong with the agent and change with it; the API is better when they are large, or maintained by someone who does not deploy. ## Flows `defineFlow` declares a deterministic conversation: the steps you wrote, in the order you wrote them. Reach for it where the model must not improvise, and leave the rest of the conversation to the agent. ```ts theme={null} import { defineAgent, defineTool, defineFlow } from "@zavudev/functions" defineAgent({ name: "Support", provider: "zavu", model: "openai/gpt-5.6-luna", prompt: "Answer support questions. Be brief.", }) defineTool({ name: "create_ticket", description: "Open a support ticket.", parameters: { type: "object", properties: { email: { type: "string" }, issue: { type: "string" } }, required: ["email", "issue"], }, handler: async ({ email, issue }) => ({ id: "TCK-1", email, issue }), }) defineFlow({ name: "Repair intake", trigger: { type: "keyword", keywords: ["broken", "repair"] }, steps: [ { id: "ask_issue", type: "collect", config: { variable: "issue", prompt: "What is wrong with it?" }, nextStepId: "ask_email", }, { id: "ask_email", type: "collect", config: { variable: "email", prompt: "What is your email?", validation: { type: "email" } }, nextStepId: "open", }, { id: "open", type: "tool", config: { toolName: "create_ticket", params: { email: "{{email}}", issue: "{{issue}}" }, }, nextStepId: "done", }, { id: "done", type: "message", config: { text: "Logged. We will email {{email}}." } }, ], }) ``` The step shapes, the validation types and what each trigger does are in the [flows guide](/guides/ai-agents/flows). Three things are specific to declaring one in code: ### A new flow arrives disabled A flow intercepts real conversations, so writing one and turning it on are separate decisions. Pass `enabled: true` to have the first deploy enable it, or turn it on afterwards: ```bash theme={null} npx zavudev agents flows update --sender "$SENDER_ID" --data '{"enabled":true}' ``` **Later deploys never change `enabled`.** Pausing a flow from the dashboard during an incident has to survive a redeploy. ### The tool a step calls must exist on the agent Declaring it with `defineTool` in the same file is enough, because tools reconcile before flows. A step naming a tool the agent does not have is reported and that flow is skipped; the rest of the deploy still lands. ### The declaration is the source of truth, with two exceptions A flow you stop declaring is deleted on the next deploy. Two things are left alone: * **A flow this function did not create.** One built in the dashboard with the same name is not adopted and not overwritten. The deploy says so. * **A flow someone is standing in.** If a contact is mid-conversation inside it, it is kept and removed on a later deploy, rather than stranding them. ## Providers ### `zavu` — managed gateway (recommended for getting started) No API key needed. LLM costs are billed directly from your Zavu balance at pass-through rates. ```ts theme={null} defineAgent({ ..., provider: "zavu", model: "openai/gpt-5.6-luna", // provider/model id, the gateway picks the right backend }) ``` Available models on the gateway: | `model` | Backend | Best for | | - | - | - | | `openai/gpt-5.6-luna` | OpenAI GPT-5.6 Luna | Default for new agents. Low cost, tool use | | `openai/gpt-4o-mini` | OpenAI GPT-4o mini | Lower price per token, older model | | `openai/gpt-4o` | OpenAI GPT-4o | High-quality reasoning | | `anthropic/claude-3-5-haiku-20241022` | Anthropic Haiku | Fast, good tool-use | | `anthropic/claude-3-5-sonnet-20241022` | Anthropic Sonnet | Best multi-step reasoning | | `google/gemini-2.5-flash-lite` | Google Flash Lite | Cheapest multilingual | | `google/gemini-2.5-flash` | Google Flash | Cheap multilingual | | `google/gemini-2.5-pro` | Google Pro | Long context windows | ### Bring your own key (BYOK) Pass `provider` matching the vendor and `apiKey` (or pre-create a secret in the dashboard and rely on it being already stored). ```ts theme={null} defineAgent({ ..., provider: "openai", model: "gpt-4o-mini", apiKey: process.env.OPENAI_API_KEY!, // set via `npx zavudev fn secrets set` }) ``` Models follow each vendor's own naming (`gpt-4o-mini`, `claude-3-5-sonnet-20241022`, `gemini-2.5-flash`, etc) — no `provider/` prefix when using BYOK. > [!TIP] > > On first deploy, if you pass `apiKey`, we create a row in `apiSecrets` > encrypted with AES-256-GCM and reference it from the agent. Subsequent > deploys without `apiKey` reuse the same stored secret. To rotate, pass a > new `apiKey` and redeploy. ## Prompts The `prompt` field is the system message every conversation starts with. It's where you set the persona, rules, and guardrails. ### Patterns that work ```ts theme={null} prompt: `You are Bella, host of Bella Pizzeria. Your job: - Help guests view the menu and book reservations. - ONLY use the tools provided — never invent prices or availability. - Confirm every reservation by reading back the time and code before closing. Tone: - Friendly, brief (WhatsApp messages should fit one screen). - Match the language the customer writes in. Edge cases: - If asked something outside your scope (delivery, jobs, complaints), say: "Para eso te paso con un humano." and stop responding. ` ``` ### Anti-patterns > [!WARNING] > > **Don't** put prices, menu items, or any data that changes in the prompt. Put > them in tool handlers so they stay current without redeploys. > > **Don't** make the prompt longer than \~2,000 characters. LLMs lose focus on > the rules with verbose prompts; tools are how you scope behavior. ## Triggers (which messages reach the agent) By default the agent fires on every inbound message to its sender. Restrict with `channels` and `messageTypes`: ```ts theme={null} defineAgent({ ..., channels: ["whatsapp"], // only WhatsApp, ignore SMS to the same sender messageTypes: ["text"], // ignore images, audio, etc }) ``` If you need fine-grained event triggers (e.g. only when a specific sender fires `message.inbound`), use [explicit triggers](/guides/functions/triggers) in addition. ## Staying silent with `NO_REPLY` When the agent's whole answer, trimmed, is exactly `NO_REPLY`, nothing is sent, on any channel. The execution is recorded as `filtered` with a message saying the agent chose not to reply. The tokens it used are still billed. Tell the agent in its prompt when to use it: ```ts theme={null} defineAgent({ ..., prompt: "... If the message is a thank-you or needs no answer, reply NO_REPLY.", }) ``` ## Group chats By default an agent answers one-to-one conversations only. Set `groups: true` to have it answer group chats too, on channels that carry them: ```ts theme={null} defineAgent({ ..., prompt: "Answer only when someone addresses you by name. Otherwise reply NO_REPLY.", groups: true, }) ``` * The reply goes to the group, not privately to the person who wrote. * The agent is asked about every message. Each line reaches it as `Name (+number): text`, or the number alone when the author has no profile name. Anyone can set any profile name, so if it matters who is speaking, have the prompt rely on the number. * The agent is told it is in a group and that `NO_REPLY` posts nothing. Put your own rule for when to stay quiet in the prompt. * Groups have no flows or handoffs, and the agent can reply at most 12 times per group per minute, so two agents in one group cannot loop forever. * Group messages reach your webhooks and Functions whether `groups` is on or off. Removing `groups` turns it off on the next deploy. > [!NOTE] > > `groups` needs a current runtime. A function pinned to an older runtime > deploys with the field ignored; deploy it with `npx zavudev deploy --update-runtime`. ## Several files Split the agent across as many files as it needs. Imports between them are resolved when you deploy: ```ts theme={null} // index.ts import { formatOrder } from "./lib/orders" defineTool({ name: "order_status", handler: async ({ id }) => formatOrder(await lookup(id)), }) ``` ```ts theme={null} // lib/orders.ts export function formatOrder(order: Order) { return `${order.id} — ${order.status}` } ``` `npx zavudev deploy` starts at your entrypoint (`index.ts` by default) and uploads every file it reaches through relative imports. Files nothing imports stay on your machine, so tests and scratch work are not deployed. npm packages are not part of that tree: declare them under `dependencies` in `package.json` and they are installed and bundled. ``` my-agent/ ├── index.ts ← entrypoint, deployed ├── lib/orders.ts ← imported, deployed ├── lib/orders.test.ts ← nothing imports it, stays local └── package.json ← dependencies, not uploaded as a file ``` A few paths are refused: anything above the project root (`../shared/x.ts`), `node_modules/`, and names starting with `__zavu`. A project can hold up to 200 files totalling 900,000 bytes. If you import something from outside the project directory, either move it inside or publish it as an npm package. The dashboard editor works the same way — add files in the sidebar and import between them. To deploy straight from a repository instead, link it: ```sh theme={null} npx zavudev fn git link acme/my-agent --branch main ``` Every push to that branch redeploys. ## One agent per file `defineAgent` is allowed multiple times only when each call has a different `(senderId, name)` pair, in which case each tool must explicitly bind to one: ```ts theme={null} defineAgent({ senderId: process.env.SENDER_A!, name: "Sales", ... }) defineAgent({ senderId: process.env.SENDER_B!, name: "Support", ... }) defineTool({ name: "lookup_order", agent: "Support", // explicit binding description: "...", parameters: { ... }, handler: async (args) => { ... }, }) ``` For most use cases you want one agent per function file. Multiple senders sharing identical logic? Use the same code in multiple functions, each with its own `SENDER_ID` secret. ## Updates and ownership Every `npx zavudev deploy` reconciles the live agent to match what's in the code. | State | Reconcile behavior | | - | - | | No agent exists yet | Create it, mark as managed by this function. | | Manually-created agent with same `(senderId, name)` | **Takes ownership**. What the code declares is applied; model settings the code leaves out (`temperature`, `maxTokens`, ...) keep their current values. From then on only the model settings can be edited in the dashboard. | | Agent managed by THIS function | Update fields to match the manifest. Model settings follow the rule below. | | Agent managed by a DIFFERENT function | Skipped with a warning. Rename in code or delete the other function first. | When you remove `defineAgent` from your code and redeploy, the managed agent is **deleted**. Manual agents are never touched. ### Model settings changed outside the code `provider`, `model`, `temperature`, `maxTokens`, `contextWindowMessages`, `includeContactMetadata` and `sessionTimeoutMinutes` can also be changed in the dashboard or with `PATCH /v1/agents/{agentId}`, to try another model without a deploy. A deploy writes one of them only when **the code changed it since the previous deploy**: * You change `model` in the code and deploy: the new model is applied. * You pick a model in the dashboard and later deploy for an unrelated reason: the dashboard's model stays, and the deploy output says the code declares a different one. * You remove `temperature` from the code: it is cleared. `provider` and `model` count as one setting. Everything else the code declares (name, prompt, channels, voice) is written on every deploy. > [!WARNING] > > Deleting the agent takes **everything attached to it**, and that is wider than > the rest of this table: its managed tools and flows, and **every knowledge base > on that agent, including one you built in the dashboard**, with its documents. > > Not an oversight. A knowledge base belongs to one agent, and every route > resolves the agent before it. Once the agent is gone nobody can list, read or > delete what was attached to it, so leaving it behind hides customer content > instead of preserving it. > > If a knowledge base has to outlive the agent, move it to another one before you > stop declaring the agent. The same applies to deleting the whole function. ## Disabling without deleting `enabled` is read once, when the deploy creates the agent. Declare `enabled: false` to create it paused. After that, deploys never change it, so a pause survives a redeploy. Pause or resume an existing agent with the Active switch in the dashboard, or `PATCH /v1/agents/{agentId}` with `{ "enabled": false }`. While it is paused the agent stays in the database and messages to its sender still reach your webhooks. ## Common patterns > [accordion] Multi-language agent > > Don't hard-code a language. Tell the prompt to match the user: > > ```ts theme={null} > prompt: `… > Always reply in the language the customer wrote in. > If unclear, default to Spanish.` > ``` > > The LLM handles language detection per turn — no need for routing logic. > [accordion] Escalation to human > > Add a tool that signals escalation (e.g., creates a ticket in your CRM > or notifies an operator on Slack). Tell the prompt when to use it: > > ```ts theme={null} > prompt: `… > If the customer asks for a refund, asks to "speak to a human", or > expresses strong frustration, call escalate_to_agent and tell them > someone will reach out within 15 minutes.` > ``` > [accordion] Different agent per sender (multi-tenant) > > Same code, different `SENDER_ID` secret in each function. The prompt can > even include `process.env.BRAND_NAME` to customize per tenant: > > ```ts theme={null} > defineAgent({ > ..., > name: process.env.BRAND_NAME!, > prompt: `You are the assistant for ${process.env.BRAND_NAME}…`, > }) > ``` > [accordion] Reading contact metadata > > With `includeContactMetadata: true` (default), the LLM sees: > > * Contact's display name (if known). > * Custom metadata fields on the contact. > * Channel they wrote from. > > Set metadata via `client.contacts.update(contactId, { metadata: {...} })`. > Useful for "Hello \$name" style personalization without a tool call. ## Next > [card] [defineTool](/guides/functions/defining-tools) > > Give the agent actions to execute. > [card] [Secrets](/guides/functions/secrets) > > Store SENDER\_ID, API keys, and other env vars. # defineTool Source: https://docs.zavu.dev/guides/functions/defining-tools Give your agent real actions — database lookups, bookings, API calls. ## `defineTool` `defineTool` declares an action the agent can take. The LLM reads the `description` to decide *when* to call it, and the `parameters` schema to decide *with what arguments*. Your `handler` runs in the function's runtime and returns the result. ```ts theme={null} import { defineTool } from "@zavudev/functions" defineTool({ name: "check_availability", description: "Check available reservation slots for a date and party size. " + "Call before create_reservation.", parameters: { type: "object", properties: { date: { type: "string", description: "YYYY-MM-DD or 'today', 'tomorrow', 'friday'.", }, partySize: { type: "number", description: "Number of people.", }, }, required: ["date", "partySize"], }, handler: async ({ date, partySize }, ctx) => { // Look up your DB / POS / external API. return { available: true, slots: ["19:00", "21:00"], date, partySize, } }, }) ``` ## How the agent decides The agent's LLM picks tools based on three signals: 1. **`description`** — primary signal. Write it as the answer to *"when should the model call this?"*. Be specific about preconditions. 2. **`parameters`** — JSON Schema. Each property's `description` helps the LLM fill in the right value. 3. **`name`** — secondary signal. Use `snake_case` action verbs: `check_availability`, `create_reservation`, `cancel_reservation`. Bad description: ```ts theme={null} description: "Booking stuff" ``` Good description: ```ts theme={null} description: "Book a confirmed reservation. ONLY call AFTER check_availability has " + "returned the requested slot. Requires customer name." ``` ## Required fields | Field | Type | Notes | | - | - | - | | `name` | string | Unique within the agent. `snake_case`. | | `description` | string | Tells the LLM *when* to call this. ≤ 500 chars in practice. | | `parameters` | JSON Schema (object) | `{ type: "object", properties, required }`. | | `handler` | async function | `(args, ctx) => result`. Return any JSON-serializable value. | ## Optional fields | Field | Default | Description | | - | - | - | | `agent` | inferred | Only needed if your file defines multiple `defineAgent`s. | | `enabled` | `true` | Set `false` to deploy the tool but keep it disabled. | ## Parameters schema Use JSON Schema. The agent's LLM is trained to fill in any standard schema — keep it simple. ### Primitive types ```ts theme={null} parameters: { type: "object", properties: { orderId: { type: "string", description: "Order ID like ORD-12345." }, quantity: { type: "number", description: "How many units." }, expedited: { type: "boolean", description: "Pay extra for next-day shipping." }, tags: { type: "array", items: { type: "string" } }, }, required: ["orderId"], } ``` ### Enums ```ts theme={null} { type: "string", enum: ["pending", "shipped", "delivered", "cancelled"], description: "Filter by status.", } ``` ### Nested objects ```ts theme={null} { type: "object", properties: { address: { type: "object", properties: { street: { type: "string" }, city: { type: "string" }, zip: { type: "string" }, }, required: ["street", "city", "zip"], }, }, } ``` ### Free-form metadata When you want to accept arbitrary key/value pairs: ```ts theme={null} { type: "object", description: "Extra metadata, arbitrary keys.", additionalProperties: { type: "string" }, } ``` > [!TIP] > > The LLM follows the schema *strictly*. If a field is `required`, the LLM will > ask the user follow-up questions until it has the value. Use this — it > removes a lot of validation from your handler. ## Handler signature ```ts theme={null} handler: async (args, ctx) => { // args: typed by your schema (use `as Type` for safety) // ctx: invocation context — see below return { /* anything JSON-serializable */ } } ``` ### `ctx` properties | Property | Type | When set | | - | - | - | | `projectId` | string | Always. | | `functionId` | string | Always. | | `slug` | string | Always. | | `messageId` | string | When invoked by the agent (not on manual HTTP call). | | `contactPhone` | string | The customer's phone number (E.164). | | `sessionId` | string \| null | If the agent is in a flow session. | | `log` | function | Equivalent to `console.log` but tagged for the dashboard logs panel. | ### Return value The handler's return value goes back to the LLM as the tool result. The LLM includes it in the next message it composes for the customer. ```ts theme={null} return { confirmed: true, reservationId: "RES-LXXY", summary: "Marco, party of 2, friday at 21:00", } ``` Becomes (paraphrased) "Listo Marco, reserva RES-LXXY confirmada para 2 personas el viernes a las 21:00." > [!NOTE] > > The LLM reads field names. Return semantically named fields (`confirmed`, > `summary`, `eta_minutes`) rather than IDs and codes only. The natural-language > answer it generates is better when the structure is self-documenting. ## Error handling Throwing from a handler returns `success: false` to the LLM with the message as the error. The LLM usually translates this into "Sorry, that didn't work because…" for the user. ```ts theme={null} defineTool({ name: "cancel_reservation", ..., handler: async ({ reservationId }) => { const r = await db.reservations.findById(reservationId) if (!r) { throw new Error("Reservation not found. Double-check the ID.") } if (r.status === "completed") { throw new Error("That reservation already happened — nothing to cancel.") } await db.reservations.cancel(reservationId) return { cancelled: true } }, }) ``` Prefer **returning** a structured error over throwing when the user can recover: ```ts theme={null} return { error: "slot_taken", message: "21:00 was just booked by someone else.", alternatives: ["19:30", "22:30"], } ``` The LLM sees the alternatives and offers them naturally. ## Calling Zavu's own API from a tool Every function has a `ZAVU_API_KEY` env var injected automatically. Use it to call your Zavu account: ```ts theme={null} import Zavudev from "@zavudev/sdk" import { defineTool } from "@zavudev/functions" const zavu = new Zavudev({ apiKey: process.env.ZAVU_API_KEY!, baseURL: process.env.ZAVU_API_BASE_URL, }) defineTool({ name: "send_thankyou", description: "Send a follow-up WhatsApp after a reservation is created.", parameters: { type: "object", properties: { phone: { type: "string" }, customerName: { type: "string" }, }, required: ["phone", "customerName"], }, handler: async ({ phone, customerName }) => { await zavu.messages.send({ to: phone, channel: "whatsapp", text: `Gracias por reservar con nosotros, ${customerName}! 🍕`, }) return { sent: true } }, }) ``` The auto-key has `messages:send`, `messages:read`, `contacts:read` scopes. For other operations, create a project-scoped API key in the dashboard and inject it as a secret. ## Calling external services Standard `fetch`. Functions have unrestricted egress (today). ```ts theme={null} defineTool({ name: "lookup_order", description: "Get the latest status of an order from our POS.", parameters: { type: "object", properties: { orderId: { type: "string" } }, required: ["orderId"] }, handler: async ({ orderId }) => { const res = await fetch(`https://pos.example.com/orders/${orderId}`, { headers: { authorization: `Bearer ${process.env.POS_API_KEY}` }, }) if (!res.ok) throw new Error(`POS returned ${res.status}`) return await res.json() }, }) ``` ## Adding npm dependencies Edit `package.json`: ```json theme={null} { "dependencies": { "openai": "^4.20.0", "zod": "^3.22.0" } } ``` `npx zavudev deploy` installs them server-side during the bundle step (no local `npm install` required). Your function ships as a self-contained bundle with the deps. > [!WARNING] > > Keep dependencies tight. Each unused package adds cold-start latency and zip > size. The runtime layer already ships `@zavudev/sdk`, `hono`, `dayjs`, `zod`, > and a few others — declaring them again is unnecessary. > [!TIP] > > If a dependency only ships **x86 prebuilds** (some image / crypto libs), opt into x86\_64 — `package.json`: > > ```json theme={null} > { > "zavu": { "architecture": "x86_64" }, > "dependencies": { "sharp": "^0.33.0" } > } > ``` > > x86 invocations cost 1.25× units vs arm64 (the default). See [Runtime → CPU architecture](/guides/functions/runtime#cpu-architecture) for full pricing impact. ## Testing locally ```sh theme={null} npx zavudev fn invoke --event message.inbound \ --data '{"from":"+14155551234","text":"menú vegano","channel":"whatsapp"}' ``` This runs your `defineFunction` handler with a synthetic message event, no cloud round-trip. Tool calls invoked by the LLM aren't simulated in local invoke — for that, use the deployed function and `npx zavudev fn logs --tail`. ## Common patterns > [accordion] Returning a list back to the LLM > > Don't return huge arrays — the LLM has limited context. Filter and trim > server-side: > > ```ts theme={null} > handler: async ({ filter }) => { > const all = await db.menu.findAll() > const filtered = filter === "vegan" ? all.filter(m => m.vegan) : all > return { > items: filtered.slice(0, 20).map(m => ({ > name: m.name, price: `$${m.price}`, vegan: m.vegan > })), > total: filtered.length, > } > } > ``` > [accordion] Long-running tasks > > Functions have a 30s budget by default (configurable up to 15min). For longer > work, return immediately with a job ID and let the LLM follow up: > > ```ts theme={null} > defineTool({ > name: "start_export", > handler: async () => { > const jobId = await queue.enqueue({ kind: "export" }) > return { > started: true, > jobId, > message: "Started. Use check_export(jobId) to see progress." > } > }, > }) > ``` > [accordion] Idempotency > > Tools can be called multiple times (LLM retries, user repeats request). > Use the tool's natural keys to dedupe: > > ```ts theme={null} > handler: async ({ orderId, action }) => { > const result = await db.actions.upsert({ > key: `${orderId}:${action}`, > runOnce: () => doTheThing(orderId), > }) > return result > } > ``` > [accordion] Multi-step confirmations > > For destructive operations, return a `confirmRequired` payload first: > > ```ts theme={null} > defineTool({ > name: "cancel_reservation", > handler: async ({ reservationId, confirm }) => { > if (!confirm) { > const r = await db.reservations.findById(reservationId) > return { > confirmRequired: true, > summary: `Cancel reservation for ${r.customerName} on ${r.date}?`, > } > } > await db.reservations.cancel(reservationId) > return { cancelled: true } > }, > }) > ``` > > The LLM will summarize the cancellation and wait for the user to confirm > before calling again with `confirm: true`. ## Next > [card] [Secrets](/guides/functions/secrets) > > Inject DB credentials and API keys. > [card] [Restaurant example](/guides/functions/examples/restaurant) > > Full booking agent walked through. # Deploy an eve project Source: https://docs.zavu.dev/guides/functions/deploy-eve-project Run an agent built with the eve framework on Zavu — no code changes, one command. ## Deploy an eve project [eve](https://eve.dev) is Vercel's open-source agent framework. If you built an agent with eve's filesystem layout, Zavu deploys it as a first-class Zavu agent — same inbox, same executions log, same billing as an agent written with `@zavudev/functions` — without editing a single file of your project. Two ways in: ```sh theme={null} # From GitHub, in one command: npx zavudev import acme/support-bot --sender sndr_123 # Or from a local checkout: cd my-eve-agent npx zavudev deploy --sender sndr_123 ``` `deploy` recognizes the eve layout by `agent/instructions.md`. `import` downloads the repo, detects the framework (eve or Zavu native), registers the function, installs dependencies, and deploys. ## What maps to what | eve | Zavu | | - | - | | `agent/instructions.md` | The agent's system prompt | | `agent/agent.ts` (`model: "..."`) | The agent's model (string models only) | | `agent/agent.ts` (`name: "..."`) | The agent's name. Without it, the `package.json` name is used | | `agent/tools/*.ts` | Tools, named by filename — `get_weather.ts` becomes `get_weather`. Each file must `export default defineTool({ ... })` | | Zod `inputSchema` | Converted to JSON Schema automatically | | `package.json` dependencies | Installed server-side, exactly like a native function | Your tool handlers run unmodified inside the function. The `eve` package itself is compiled away — it is not installed or executed on Zavu. ## The sender An eve project has no concept of a sender, and the agent needs one to answer messages. Provide it either way: ```sh theme={null} npx zavudev deploy --sender sndr_123 # inlined at compile time # or npx zavudev fn secrets set SENDER_ID sndr_123 # resolved at runtime ``` Without either, the deploy succeeds and the agent stays disconnected until you connect a sender from the dashboard. ## What does not run on Zavu The deploy prints a compatibility report before anything ships. Every eve capability without a Zavu equivalent is listed there — nothing is dropped silently: * **`agent/channels/`** — not needed: your Zavu senders are the channels. Messages arrive without any channel file. * **`agent/schedules/`** — use a Zavu cron trigger instead: `npx zavudev fn triggers add --events cron --cron '0 9 * * 1-5'`. * **`agent/skills/`** — skill files are not loaded at runtime. Fold critical content into `instructions.md` or a knowledge base. * **`agent/subagents/`, `agent/sandbox/`** — not supported. If your agent depends on them, run it on eve's own runtime and connect Zavu as a channel instead. * **`evals/`** — keep running them locally with eve; they do not run on Zavu. * **Tool `approval` gates and `toModelOutput`** — ignored, with a warning per tool. * **A file under `agent/tools/` that does not `export default defineTool(...)`** — it is not a tool and is reported by name. The tool count in the report is always the number of tools that will actually be created: ```ts theme={null} // Not a tool: nothing registers it, and the agent's prompt will reference // something that does not exist. export default { description: "...", execute: async () => ({}) } // A tool. import { defineTool } from "eve/tools" export default defineTool({ description: "...", execute: async () => ({}) }) ``` * **A `voice` block in `agent/agent.ts`** — not translated. The agent deploys as **text only** and will not answer phone calls. To give it a voice, declare it on a Zavu-native agent (`defineAgent({ voice: { ... } })`) or turn voice on from the dashboard. `agent/agent.ts` is read against a fixed list of keys: `model` and `name`. **Every other key it declares is reported by name**, whether or not Zavu has heard of it, so a capability eve adds tomorrow cannot slip through unmentioned: ```text theme={null} agent config 'reasoning' is not translated to Zavu and will be ignored agent config 'voice' is not translated to Zavu and will be ignored — this agent deploys as TEXT ONLY and will not answer phone calls ``` Tools run on every channel — plain text, voice, and flow tool steps — with up to 5 tool rounds per reply, exactly as an eve agent expects. ## Importing from GitHub ```sh theme={null} npx zavudev import owner/repo # default branch npx zavudev import owner/repo#staging # branch, tag, or commit npx zavudev import owner/repo --root apps/agent # monorepo subdirectory ``` Private repositories work by setting a `GITHUB_TOKEN` environment variable. The token is sent only to GitHub to download the archive — never to Zavu. `import` works for Zavu-native projects too: a repo with a root `index.ts` is registered and deployed exactly like `npx zavudev init` + `deploy` would. ## After the first deploy ```sh theme={null} npx zavudev agents list # find the agent id npx zavudev agents test --message "hi" # dry-run, nothing delivered ``` Redeploys are just `npx zavudev deploy` from the project directory — edit your eve files, deploy, and the reconcile summary shows exactly what changed (`+` created, `~` updated, `=` unchanged, `-` deleted). # Deploy from GitHub Source: https://docs.zavu.dev/guides/functions/deploy-from-github Link a repository to a function and deploy on every push — Zavu native and eve projects. ## Deploy from GitHub Two ways to get a GitHub repository running on Zavu: * **One-off import** from your terminal: `npx zavudev import owner/repo` (see the [CLI reference](/guides/functions/cli)). * **Deploy on push**: link the repository to a function in the dashboard, add the webhook to GitHub, and every push to the linked branch deploys automatically. This page covers that flow. ## Linking a repository From the terminal, in the function's directory: ```sh theme={null} npx zavudev fn git link acme/support-bot --branch main ``` It takes `owner/repo`, a github.com URL, or an SSH remote, and `--root` for a monorepo. What it prints next depends on your project. With the Zavu GitHub App installed, that is the whole setup: pushes arrive through the app and private repositories work. Without it, the command prints a payload URL and a secret to add as a webhook in the repository yourself, and the secret is shown once. `fn git status` shows the link and the last deploy, `fn git deploy` deploys the linked branch without waiting for a push, and `fn git unlink` removes it. Or do the same in the dashboard: open the function and go to the **Git** tab: 1. Enter the repository owner, name, and branch (and a root directory for monorepos). 2. Zavu shows a **payload URL** and a **webhook secret**. The secret is shown once — copy it now. 3. In the repository's GitHub settings, add a webhook with that URL and secret, content type `application/json`, events: just the push event. GitHub sends a ping when the webhook is created; from then on, every push to the linked branch fetches the repo, detects the project type, builds, and deploys through the same pipeline as `npx zavudev deploy`. ## What gets detected | Layout | Detected by | Deployed as | | - | - | - | | Zavu native | root `index.ts` | The file, as-is | | eve | `agent/instructions.md` | Compiled to a Zavu agent ([details](/guides/functions/deploy-eve-project)) | Anything else fails the deploy with a clear error in the Git tab's status line. For monorepos, point the link's root directory at the project folder. ## Manual deploys and status The Git tab shows the last deploy's commit, message, and outcome, and a **Deploy now** button that fetches the linked branch on demand — useful after changing secrets or to retry a failed deploy. Turning **Deploy on push** off keeps the link but ignores pushes. ## Security notes * Each link has its own webhook secret. Every delivery is verified against it (HMAC), and a signature that does not match is ignored — deliveries can never deploy another project's function. * Re-linking generates a fresh secret; the old one stops working immediately. * Public repositories work out of the box. For private repositories, your Zavu deployment needs a GitHub token configured server-side — contact support, or use `npx zavudev import` with a local `GITHUB_TOKEN` instead. # Customer support agent Source: https://docs.zavu.dev/guides/functions/examples/customer-support KB lookup + ticket creation + escalation, all in one function. ## Customer support agent An agent that handles tier-1 support: answers FAQs from a knowledge base, creates support tickets when it can't, and escalates urgent cases to a human. ## Architecture ``` Customer message | v +----------------+ | AI Agent | +----------------+ | tools: ├─ search_kb → returns relevant FAQ snippets ├─ create_ticket → opens a ticket in your help desk ├─ check_ticket → status lookup └─ escalate_to_human → pages on-call via Slack ``` ## Setup ```sh theme={null} npx zavudev fn init --slug support -y cd support ``` Replace `index.ts`: ```ts theme={null} import { defineAgent, defineTool } from "@zavudev/functions" defineAgent({ senderId: process.env.SENDER_ID!, name: "Aria", provider: "zavu", model: "anthropic/claude-3-5-haiku-20241022", // good at instruction-following channels: ["whatsapp"], prompt: `Eres Aria, asistente de soporte de Acme Corp. Tu misión: 1. Resolver preguntas frecuentes usando search_kb antes de cualquier otra cosa. 2. Si la pregunta es específica del cliente (cuenta, factura, pedido), crea un ticket con create_ticket. 3. Si el cliente está enojado o pide "hablar con alguien", llama escalate_to_human y promete que alguien responderá en 15 minutos. Reglas: - Respuestas cortas (WhatsApp). - Cita la fuente del KB cuando uses información de ahí. - NO inventes información. Si search_kb no devuelve nada relevante, di "no tengo esa información" y crea un ticket.`, }) defineTool({ name: "search_kb", description: "Search the knowledge base for FAQs, policies, how-tos. Call FIRST for any informational question.", parameters: { type: "object", properties: { query: { type: "string", description: "Customer's question, paraphrased if needed." }, topK: { type: "number", description: "Max results (default 3, max 10)." }, }, required: ["query"], }, handler: async ({ query, topK = 3 }) => { // Replace with your real vector search. Here we use the Convex action exposed // via the SDK (Zavu's built-in agent knowledge base). const results = await searchKnowledgeBase(query, topK) return { results: results.map((r) => ({ title: r.title, excerpt: r.excerpt, source: r.url, })), count: results.length, } }, }) defineTool({ name: "create_ticket", description: "Open a support ticket when you can't resolve the issue with KB lookup. " + "Use for account-specific questions (billing, orders, refunds, account changes).", parameters: { type: "object", properties: { subject: { type: "string", description: "Short title, 5-10 words." }, details: { type: "string", description: "Full context of the issue, what the customer said." }, priority: { type: "string", enum: ["low", "normal", "high", "urgent"], description: "low = informational, urgent = revenue/account at risk", }, }, required: ["subject", "details", "priority"], }, handler: async ({ subject, details, priority }, ctx) => { const ticket = await fetch(`${process.env.HELPDESK_URL}/tickets`, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${process.env.HELPDESK_API_KEY}`, }, body: JSON.stringify({ subject, details, priority, contactPhone: ctx?.contactPhone, source: "whatsapp", }), }).then((r) => r.json()) return { ticketId: ticket.id, eta: priority === "urgent" ? "15 minutos" : "1 día hábil", summary: `Ticket #${ticket.id} creado. Te respondemos en ${ priority === "urgent" ? "15 min" : "1 día hábil" }.`, } }, }) defineTool({ name: "check_ticket", description: "Look up the status of an existing ticket by ID.", parameters: { type: "object", properties: { ticketId: { type: "string" } }, required: ["ticketId"], }, handler: async ({ ticketId }, ctx) => { const res = await fetch(`${process.env.HELPDESK_URL}/tickets/${ticketId}`, { headers: { authorization: `Bearer ${process.env.HELPDESK_API_KEY}` }, }) if (!res.ok) { return { error: "not_found", message: `No encuentro el ticket #${ticketId}.` } } const ticket = await res.json() // Privacy guard: only return if the contact owns this ticket. if (ticket.contactPhone !== ctx?.contactPhone) { return { error: "not_yours", message: "Ese ticket no está asociado a tu número." } } return { ticketId, status: ticket.status, lastUpdate: ticket.updatedAt, summary: ticket.publicSummary, } }, }) defineTool({ name: "escalate_to_human", description: "Page the on-call team via Slack. Call when the customer is frustrated, " + "explicitly asks for a human, or the issue is time-sensitive and outside your scope.", parameters: { type: "object", properties: { reason: { type: "string", description: "Why escalating (frustration, complexity, urgency)." }, summary: { type: "string", description: "What the customer needs, in 1-2 sentences." }, }, required: ["reason", "summary"], }, handler: async ({ reason, summary }, ctx) => { await fetch(process.env.SLACK_WEBHOOK_URL!, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text: `:rotating_light: *Customer needs human support*\n` + `*From:* ${ctx?.contactPhone}\n` + `*Reason:* ${reason}\n` + `*Summary:* ${summary}\n` + ``, }), }) return { escalated: true, eta: "15 minutos", summary: "Un agente humano responderá en aproximadamente 15 minutos.", } }, }) ``` ## Knowledge base implementation Several options for `searchKnowledgeBase`: ### Option A: Zavu's built-in agent knowledge base If you already use [AI Agents knowledge bases](/guides/ai-agents/knowledge-base), you do not need this tool at all. Retrieval runs automatically on every inbound message: Zavu pulls the relevant chunks and puts them in the agent's context before the model answers. Attach the knowledge base to the agent and delete `searchKnowledgeBase`. To confirm an answer was actually grounded in your documents, read `knowledgeChunksUsed` on the execution: ```sh theme={null} npx zavudev agents executions --sender "$SENDER_ID" --json ``` A zero there on an agent that has documents attached means the reply was not grounded in them. > [!NOTE] > > There is no public endpoint to query a knowledge base directly. If you need > retrieval you can call yourself, from a tool or from your own backend, use your > own vector store as in Option B. ### Option B: Your own vector store (Pinecone, Qdrant, pgvector) ```ts theme={null} async function searchKnowledgeBase(query: string, topK: number) { const embedding = await openai.embeddings.create({ model: "text-embedding-3-small", input: query, }) const results = await pinecone.index("kb").query({ vector: embedding.data[0].embedding, topK, includeMetadata: true, }) return results.matches.map((m) => ({ title: m.metadata.title, excerpt: m.metadata.excerpt, url: m.metadata.url, })) } ``` Set the secrets: ```sh theme={null} npx zavudev fn secrets set OPENAI_API_KEY sk-... npx zavudev fn secrets set PINECONE_API_KEY ... ``` ### Option C: Static FAQ (good for small KBs) For \< 50 FAQs, embedded text search is fine: ```ts theme={null} const FAQS = [ { title: "Refund policy", text: "We refund unused items within 30 days. Original receipt required.", url: "https://help.acme.com/refunds", }, // ... ] async function searchKnowledgeBase(query: string, topK: number) { const tokens = query.toLowerCase().split(/\s+/) const scored = FAQS.map((faq) => ({ ...faq, score: tokens.filter((t) => faq.text.toLowerCase().includes(t)).length, })) return scored .filter((f) => f.score > 0) .sort((a, b) => b.score - a.score) .slice(0, topK) } ``` Bumps to vector search later are a few lines of change. ## Deploy ```sh theme={null} npx zavudev fn secrets set SENDER_ID jn76vnxet8g5nq661by3v06y1581bmmn npx zavudev fn secrets set HELPDESK_URL https://helpdesk.acme.com/api npx zavudev fn secrets set HELPDESK_API_KEY ... npx zavudev fn secrets set SLACK_WEBHOOK_URL https://hooks.slack.com/services/... npx zavudev deploy ``` ## Sample conversation ``` 👤 cuánto tiempo tengo para devolver? 🤖 [search_kb(query="refund policy")] 🤖 Tienes 30 días desde la compra para devolver productos sin usar. Necesitas el recibo original. 👤 ok ahora dime el estado de mi pedido #4521 🤖 [check_ticket(ticketId="4521") → not_yours] 🤖 No tengo registro de un pedido vinculado a tu número. ¿Lo compraste con otra cuenta? 👤 sí, lo compró mi pareja 🤖 [create_ticket(subject="Order status request", details="Customer asking about order #4521 made by their partner. Need verification.", priority="normal")] 🤖 Ticket #8842 creado. Un agente verificará la info de cuenta y te responde dentro de 1 día hábil. 👤 esto es ridículo, llevo 3 horas esperando! 🤖 [escalate_to_human(reason="Customer frustration", summary="Asking about partner's order #4521. Felt previous interactions too slow.")] 🤖 Lamento la espera. Acabo de notificar al equipo y alguien te escribe en los próximos 15 minutos. ¿Necesitas algo más mientras tanto? ``` ## Improvements you can layer > [accordion] Sentiment-driven escalation > > Detect frustration from message text without relying on the LLM's judgment: > > ```ts theme={null} > const FRUSTRATION_TOKENS = [ > "ridículo", "ridiculous", "horrible", "terrible", > "harto", "fed up", "cancelo", "cancel my", "refund now" > ] > > // Add a trigger > export default defineFunction(async (event) => { > if (event.type !== "message.inbound") return > const text = (event.data.text ?? "").toLowerCase() > if (FRUSTRATION_TOKENS.some(t => text.includes(t))) { > await pageOnCall({ phone: event.data.from, text }) > } > }) > ``` > > Then `npx zavudev fn triggers add --events message.inbound`. This runs IN > ADDITION to the agent — observer pattern. > [accordion] Business hours awareness > > ```ts theme={null} > defineTool({ > name: "escalate_to_human", > ..., > handler: async ({ reason, summary }, ctx) => { > const isWorkHours = checkWorkHours() > if (!isWorkHours) { > await pageOnCall(/* ... */) > return { > escalated: true, > summary: > "Estamos fuera de horario (Lun-Vie 9-18). Te respondemos mañana " + > "a primera hora, o llama urgencias al +1-800-xxx.", > } > } > // normal flow > }, > }) > ``` > [accordion] Customer language detection > > Don't hard-code Spanish. Tell the agent: > > ```ts theme={null} > prompt: `… > Always reply in the language the customer wrote in. > Default to Spanish if unclear.` > ``` > > Tool descriptions can stay in English — the LLM translates the natural > responses. Tool return values (like ticket summaries) can be templates > you i18n yourself. > [accordion] Daily metrics summary > > Add a scheduled trigger (when we support cron in Functions) or rely on a > nightly external cron that calls a function-only HTTP path: > > ```ts theme={null} > export default defineFunction(async (event) => { > if (event.type === "scheduled.daily-report") { > const stats = await summarizeYesterday() > await zavu.messages.send({ > to: process.env.MANAGER_PHONE!, > channel: "whatsapp", > text: `Soporte ayer:\n• ${stats.handled} resueltos por la IA\n• ${stats.escalated} escalados\n• ${stats.avgResponseSec}s avg response`, > }) > } > }) > ``` ## Tuning | Symptom | Fix | | - | - | | Agent creates tickets for things it could answer | Beef up the KB. Add the missing FAQ. Test with `npx zavudev fn invoke`. | | Agent answers from "memory" / hallucinates | Reinforce the prompt: "ONLY use information returned by search\_kb". | | Tickets are too verbose / wrong priority | Add examples in the prompt: `Priority guide:\n- urgent: customer mentions money lost\n- high: account locked\n- normal: question about a feature\n- low: general info`. | | Customers ping pong between tools without resolution | Add a `max_turns` heuristic in the prompt: "If after 3 tool calls you don't have an answer, escalate\_to\_human". | ## Next > [card] [Ecommerce example](/guides/functions/examples/ecommerce) > > Order tracking, recommendations, cart recovery. > [card] [Define agents in depth](/guides/functions/defining-agents) > > Provider selection, prompts, advanced config. # Ecommerce assistant Source: https://docs.zavu.dev/guides/functions/examples/ecommerce Order tracking, smart recommendations, cart recovery — over WhatsApp. ## Ecommerce assistant A WhatsApp shopper that does three things really well: 1. **Track orders** — "where's my package?" answered in 1 turn. 2. **Recommend products** — "I need a gift for my sister, \$50 budget". 3. **Cart recovery** — proactive nudges when someone abandons a cart. This example mixes **two patterns**: the agent (reactive — replies to inbound) and a trigger (proactive — fires on inventory events). ## Setup ```sh theme={null} npx zavudev fn init --slug shopper -y cd shopper ``` ## The agent + tools ```ts index.ts theme={null} import { defineAgent, defineTool, defineFunction } from "@zavudev/functions" import Zavudev from "@zavudev/sdk" const zavu = new Zavudev({ apiKey: process.env.ZAVU_API_KEY!, baseURL: process.env.ZAVU_API_BASE_URL, }) defineAgent({ senderId: process.env.SENDER_ID!, name: "Shop Helper", provider: "zavu", model: "openai/gpt-5.6-luna", channels: ["whatsapp"], prompt: `Eres el asistente de Acme Shop en WhatsApp. Tu trabajo: - Ayudar a los clientes a encontrar productos (call recommend). - Decirles el estado de sus pedidos (call lookup_order). - Tomar el pedido si quieren comprar directo desde el chat (call create_order). Tono: - Amistoso, breve. - SIEMPRE incluye el precio cuando recomiendes un producto. - Si no encuentras lo que piden, ofrece alternativas, nunca digas "no tengo". Reglas: - Nunca inventes IDs, precios o stock — usa las tools. - Si el cliente pregunta por algo no relacionado a la tienda, dile: "Para eso te paso con un agente humano" y llama escalate.`, }) // 1) Order lookup defineTool({ name: "lookup_order", description: "Get the status of a customer's order. Use when they ask about delivery, " + "tracking, where their package is.", parameters: { type: "object", properties: { orderId: { type: "string", description: "Order number like ACME-12345." }, }, required: ["orderId"], }, handler: async ({ orderId }, ctx) => { const order = await fetch( `${process.env.SHOP_API_URL}/orders/${orderId}`, { headers: { authorization: `Bearer ${process.env.SHOP_API_KEY}` } } ).then((r) => (r.ok ? r.json() : null)) if (!order) return { error: "not_found", message: `No encuentro el pedido ${orderId}.` } if (order.customerPhone !== ctx?.contactPhone) { return { error: "not_yours", message: "Ese pedido está vinculado a otro número." } } return { orderId: order.id, status: order.status, // "processing" | "shipped" | "out_for_delivery" | "delivered" eta: order.estimatedDeliveryAt, tracking: order.trackingUrl, items: order.items.map((i) => `${i.qty}× ${i.name}`), summary: order.status === "shipped" ? `Tu pedido fue despachado. Llega aprox ${formatDay(order.estimatedDeliveryAt)}. Tracking: ${order.trackingUrl}` : order.status === "delivered" ? `Ya fue entregado el ${formatDay(order.deliveredAt)}. ¿Algún problema?` : `Estado: ${order.status}. ETA: ${formatDay(order.estimatedDeliveryAt)}`, } }, }) // 2) Product recommendation defineTool({ name: "recommend", description: "Search the catalog and return 3-5 products matching the customer's needs. " + "Use when they describe what they want (e.g. 'a gift for my dad under $80, " + "he likes camping').", parameters: { type: "object", properties: { query: { type: "string", description: "Customer's request, paraphrased." }, maxPrice: { type: "number", description: "Budget cap in USD." }, category: { type: "string", description: "Filter by category if obvious." }, }, required: ["query"], }, handler: async ({ query, maxPrice, category }) => { const res = await fetch(`${process.env.SHOP_API_URL}/search`, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${process.env.SHOP_API_KEY}`, }, body: JSON.stringify({ query, filters: { ...(maxPrice ? { priceLte: maxPrice } : {}), ...(category ? { category } : {}), inStock: true, }, limit: 5, }), }).then((r) => r.json()) return { results: res.products.map((p) => ({ id: p.id, name: p.name, price: `$${p.price}`, url: `${process.env.SHOP_API_URL}/p/${p.slug}`, whyGoodFit: p.relevanceReason, // your search engine's explanation })), count: res.products.length, } }, }) // 3) Create an order from chat defineTool({ name: "create_order", description: "Place an order on behalf of the customer. ONLY call after they explicitly " + "confirmed the items and shipping address. Always confirm the total before " + "calling.", parameters: { type: "object", properties: { items: { type: "array", items: { type: "object", properties: { productId: { type: "string" }, quantity: { type: "number" }, }, required: ["productId", "quantity"], }, }, shippingAddress: { type: "string", description: "Full address line." }, paymentMethod: { type: "string", enum: ["card_on_file", "cash_on_delivery"], }, }, required: ["items", "shippingAddress", "paymentMethod"], }, handler: async ({ items, shippingAddress, paymentMethod }, ctx) => { const res = await fetch(`${process.env.SHOP_API_URL}/orders`, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${process.env.SHOP_API_KEY}`, }, body: JSON.stringify({ items, shippingAddress, paymentMethod, customerPhone: ctx?.contactPhone, source: "whatsapp", }), }).then((r) => r.json()) if (res.error) { return { error: res.error, message: res.message } } return { orderId: res.id, total: `$${res.total}`, eta: res.estimatedDeliveryAt, summary: `Pedido ${res.id} creado por $${res.total}. ` + `Envío a "${shippingAddress.slice(0, 40)}…". ` + `Llega aprox ${formatDay(res.estimatedDeliveryAt)}.`, } }, }) // 4) Escalation defineTool({ name: "escalate", description: "Forward this conversation to a human agent. Call when the customer needs " + "help outside shopping (returns disputes, account issues, complaints).", parameters: { type: "object", properties: { reason: { type: "string" } }, required: ["reason"], }, handler: async ({ reason }, ctx) => { await fetch(process.env.SLACK_WEBHOOK_URL!, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text: `:wave: Escalation from ${ctx?.contactPhone}: ${reason}`, }), }) return { escalated: true, summary: "Te paso con un agente. Respuesta en ~10 min." } }, }) // PROACTIVE: cart recovery trigger. // // When your shop backend sends a `cart.abandoned` event to your Zavu function // (via your own webhook → custom event → trigger fanout), the agent fires off // a nudge message. // // For this to work, you'd: // 1. Have your shop POST to your Zavu function's HTTP URL (httpEnabled=true) // with the abandoned-cart payload. // 2. Format and send a reminder via the SDK. export default defineFunction(async (event, ctx) => { // Handle non-standard event from your shop: shop posts directly to fn URL. // event.body is a stringified JSON body when called via the public HTTP path. const body = typeof event.body === "string" ? JSON.parse(event.body) : event if (body.type !== "cart.abandoned") return { ok: true } const { customerPhone, items, cartUrl, total } = body await zavu.messages.send({ to: customerPhone, channel: "whatsapp", "Zavu-Sender": process.env.SENDER_ID!, text: `¡Hola! Vi que dejaste algo en tu carrito 🛒\n\n` + items.slice(0, 3).map((i: any) => `• ${i.name}`).join("\n") + (items.length > 3 ? `\n• +${items.length - 3} más` : "") + `\n\nTotal: $${total}\n${cartUrl}\n\n¿Querés que te ayude a finalizar?`, }) return { ok: true, nudged: true } }) function formatDay(iso: string) { return new Date(iso).toLocaleDateString("es-ES", { weekday: "long", day: "numeric", month: "short", }) } ``` For cart recovery via the function's public URL, you need `httpEnabled: true` — but the function is then publicly addressable. Two ways to secure: ### Option A: HMAC the cart webhook Make your shop sign payloads: ```ts theme={null} import { verifyWebhook } from "@zavudev/functions" export default defineFunction(async (event) => { if (typeof event.body !== "string") return { ok: true } const sig = event.headers?.["x-shop-signature"] if (!verifyWebhook(event.body, sig, process.env.SHOP_WEBHOOK_SECRET!)) { return { statusCode: 401, body: '{"error":"invalid_signature"}' } } // ... }) ``` ### Option B: Use Zavu triggers instead If your shop is also "inside Zavu" (rare for ecommerce, common for messaging), you can use a custom event type. For most shops, Option A is the answer. ## Sample conversations ### Order tracking ``` 👤 dónde está mi pedido ACME-9421? 🤖 [lookup_order(orderId="ACME-9421")] 🤖 Tu pedido fue despachado. Llega aprox lunes 18 nov. Tracking: https://tracker.com/ACME-9421-XYZ ¿Algo más? ``` ### Discovery → purchase ``` 👤 busco un regalo para mi hermana, le gusta cocinar, hasta $60 🤖 [recommend(query="kitchen gift for sister", maxPrice=60, category="kitchen")] 🤖 Te muestro 3 opciones: 🥘 Olla de hierro fundido 4Qt — $54 — perfecto para guisos https://shop.com/p/olla-4qt 🔪 Set de 3 cuchillos japoneses — $48 — calidad pro https://shop.com/p/knife-set 📚 Libro "Salt Fat Acid Heat" — $32 — el clásico moderno https://shop.com/p/sfah ¿Cuál te gusta? 👤 la olla, mándamela a Av Corrientes 1234, Buenos Aires 🤖 ¿Te confirmo el pedido? • Olla de hierro fundido 4Qt — $54 Envío a Av Corrientes 1234, Buenos Aires — gratis Total: $54 con tarjeta guardada. ¿Confirmamos? 👤 sí 🤖 [create_order(...)] 🤖 ¡Listo! Pedido ACME-9425 creado por $54. Envío a "Av Corrientes 1234, Buenos Aires…". Llega aprox jueves 21 nov. ``` ### Proactive cart recovery The shop's backend posts to `https://fn.zavu.dev//shopper` 30 min after a customer abandons checkout. The function sends the nudge: ``` 🤖 ¡Hola! Vi que dejaste algo en tu carrito 🛒 • Olla de hierro fundido 4Qt • Set de 3 cuchillos japoneses Total: $102 https://shop.com/cart/r/abc123 ¿Querés que te ayude a finalizar? 👤 sí, hacé el pedido 🤖 [recommend ... or directly create_order if confirmed] ``` ## Deploy ```sh theme={null} npx zavudev fn secrets set SENDER_ID jn76vnxet8g5nq661by3v06y1581bmmn npx zavudev fn secrets set SHOP_API_URL https://shop.acme.com/api npx zavudev fn secrets set SHOP_API_KEY ... npx zavudev fn secrets set SHOP_WEBHOOK_SECRET ... npx zavudev fn secrets set SLACK_WEBHOOK_URL https://hooks.slack.com/... npx zavudev deploy ``` If you want cart recovery, also enable HTTP on the function (via dashboard Function detail page → toggle "HTTP enabled") or pass `httpEnabled: true` at first init. Note that this exposes the public URL — make sure your HMAC verification is in place. ## Metrics worth watching ```sh theme={null} # Conversion rate: messages sent → orders created npx zavudev agents executions --sender $SENDER_ID --json | jq ' group_by(.status) | map({status: .[0].status, count: length})' # Token cost per conversation (gpt-5.6-luna ≈ $0.0004 / 3-turn convo) npx zavudev agents executions --sender $SENDER_ID --json | jq ' [.[].cost] | add' # Cart recovery effectiveness — track which abandoned carts converted # (your own analytics, since the agent doesn't see this) ``` ## Composition with other Zavu primitives > [card] [Broadcasts](/guides/broadcasts/overview) > > Black Friday: broadcast a 20% off code to your list. The agent picks up > when customers reply with questions. > [card] [Templates](/guides/whatsapp/templates/overview) > > For the initial "your order is shipped" notification (outside the 24h > window), use a WhatsApp template instead of the agent. > [card] [Contacts](/guides/contacts/overview) > > Store `lastOrderId`, `lifetimeValue`, `preferredCategory` on contacts. > The agent reads them automatically via `includeContactMetadata: true`. > [card] [Sub-accounts](/guides/sub-accounts/overview) > > Run separate agents for different storefronts under the same parent > Zavu account. ## Next > [card] [Define tools in depth](/guides/functions/defining-tools) > > JSON schemas, error handling, the LLM's selection logic. > [card] [Triggers](/guides/functions/triggers) > > Native event subscriptions without HTTP. # Restaurant booking agent Source: https://docs.zavu.dev/guides/functions/examples/restaurant Complete walkthrough — menu, availability, reservations over WhatsApp. ## Restaurant booking agent A full agent that handles three things customers want from a restaurant on WhatsApp: 1. **Browse the menu** (with filters: vegan, gluten-free). 2. **Check availability** for a date and party size. 3. **Confirm a reservation** with a code they can quote on arrival. By the end you'll have \~200 lines of TypeScript, no infrastructure to manage, and the whole thing deploys with one command. > [!NOTE] > > The `restaurant-booking` template ships with the CLI: > > ```sh theme={null} > npx zavudev fn init --template restaurant-booking -y > ``` > > This guide walks through what that template produces and how to evolve it > into a production setup with real data. ## What you'll build ``` 👤 hola, qué tienen vegano? 🤖 [view_menu(filter=vegan)] 🤖 Tenemos Vegan Buddha Bowl ($11) y ensaladas. ¿Te muestro alguna específica? 👤 sí, busco mañana viernes para 2 personas 🤖 [check_availability(date=friday, partySize=2)] 🤖 Tengo disponibilidad 19:30, 21:00, 22:30. ¿Cuál te conviene? 👤 21 🤖 ¿A qué nombre la reservo? 👤 Marco 🤖 [create_reservation(date=friday, time=21:00, partySize=2, customerName=Marco)] 🤖 ¡Listo Marco! Reserva RES-LXXY confirmada para 2 personas, viernes a las 21:00. Te esperamos. 🍕 ``` ## 1. Scaffold ```sh theme={null} npx zavudev fn init --template restaurant-booking --slug bella -y cd bella ``` `index.ts` is the entire integration: ```ts index.ts theme={null} import { defineAgent, defineTool } from "@zavudev/functions" defineAgent({ senderId: process.env.SENDER_ID!, name: "Bella", provider: "zavu", model: "openai/gpt-5.6-luna", channels: ["whatsapp"], prompt: `Eres Bella, anfitriona de Bella Pizzeria en WhatsApp. Ayudas al cliente a ver el menú, consultar disponibilidad y hacer reservas. Reglas: - Respuestas cortas. Es WhatsApp. - Nunca inventes precios ni horarios — usa siempre las tools. - Confirma cada reserva con su código antes de cerrarla. - Habla en el idioma del cliente.`, }) // Demo data — replace with your POS / DB. const MENU = [ { name: "Pizza Margherita", price: 12, vegan: false }, { name: "Pizza Pepperoni", price: 14, vegan: false }, { name: "Vegan Buddha Bowl", price: 11, vegan: true }, { name: "Caesar Salad", price: 9, vegan: false }, { name: "Tiramisu", price: 7, vegan: false }, ] defineTool({ name: "view_menu", description: "Get the restaurant menu. Use when the customer asks what's available, prices, or vegan options.", parameters: { type: "object", properties: { filter: { type: "string", description: "all | vegan" }, }, required: [], }, handler: async ({ filter }) => { const items = filter === "vegan" ? MENU.filter((m) => m.vegan) : MENU return { menu: items.map((m) => ({ name: m.name, price: `$${m.price}`, vegan: m.vegan, })), count: items.length, } }, }) defineTool({ name: "check_availability", description: "Check available reservation slots for a date and party size. Call before create_reservation.", parameters: { type: "object", properties: { date: { type: "string", description: "YYYY-MM-DD or 'today', 'tomorrow', 'friday'" }, partySize: { type: "number" }, }, required: ["date", "partySize"], }, handler: async ({ date, partySize }) => { const weekend = /(fri|sat)/i.test(date) const slots = weekend ? ["18:00", "19:30", "21:00", "22:30"] : ["19:00", "21:00"] return { available: slots.length > 0, slots, date, partySize } }, }) const reservations: Record = {} defineTool({ name: "create_reservation", description: "Book a confirmed reservation. Only call AFTER check_availability returns the requested slot.", parameters: { type: "object", properties: { date: { type: "string" }, time: { type: "string" }, partySize: { type: "number" }, customerName: { type: "string" }, }, required: ["date", "time", "partySize", "customerName"], }, handler: async ({ date, time, partySize, customerName }, ctx) => { const id = `RES-${Date.now().toString(36).toUpperCase()}` reservations[id] = { id, date, time, partySize: Number(partySize), customerName, phone: ctx?.contactPhone ?? null, status: "confirmed", createdAt: new Date().toISOString(), } return { confirmed: true, reservationId: id, summary: `${customerName}, party of ${partySize}, ${date} at ${time}`, cancellationCode: id.slice(-4), } }, }) defineTool({ name: "view_reservation", description: "Look up an existing reservation by its ID.", parameters: { type: "object", properties: { reservationId: { type: "string" } }, required: ["reservationId"], }, handler: async ({ reservationId }) => { const r = reservations[reservationId] if (!r) return { error: "not_found", reservationId } return r }, }) ``` ## 2. Configure ```sh theme={null} npx zavudev senders list # find your WhatsApp sender ID npx zavudev fn secrets set SENDER_ID jn76vnxet8g5nq661by3v06y1581bmmn ``` ## 3. Deploy ```sh theme={null} npx zavudev deploy ``` Watch the summary: ``` ✓ Deployed in 14s Agents synced: + Bella Tools synced: + view_menu + check_availability + create_reservation + view_reservation ``` ## 4. Test Send a WhatsApp to the sender's number: > hola, qué tienen vegano? The agent answers, calls tools, and confirms a reservation. ```sh theme={null} # In another terminal, watch what's happening npx zavudev fn logs --tail ``` ``` 2026-05-11T19:15:32.123Z tool call: view_menu { filter: 'vegan' } 2026-05-11T19:15:48.456Z tool call: check_availability { date: 'friday', partySize: 2 } 2026-05-11T19:16:12.789Z tool call: create_reservation { date: 'friday', time: '21:00', partySize: 2, customerName: 'Marco' } ``` ## Moving to production The template uses in-memory state — fine for demo, useless in real life (cold starts wipe it). ### Step 1: persist reservations Add a real database. We'll use Postgres via the `postgres` npm package: ```sh theme={null} npx zavudev fn secrets set DATABASE_URL "postgresql://user:pass@host/db?sslmode=require" ``` ```json package.json theme={null} { "dependencies": { "postgres": "^3.4.0" } } ``` ```ts theme={null} import postgres from "postgres" const sql = postgres(process.env.DATABASE_URL!, { ssl: "require" }) defineTool({ name: "create_reservation", description: "...", parameters: { /* same */ }, handler: async ({ date, time, partySize, customerName }, ctx) => { const [row] = await sql` INSERT INTO reservations (date, time, party_size, customer_name, phone, status) VALUES (${date}, ${time}, ${partySize}, ${customerName}, ${ctx?.contactPhone ?? null}, 'confirmed') RETURNING id ` return { confirmed: true, reservationId: `RES-${row.id}`, summary: `${customerName}, party of ${partySize}, ${date} at ${time}`, } }, }) ``` ### Step 2: real availability Replace the demo logic with a query against your reservations + capacity: ```ts theme={null} defineTool({ name: "check_availability", description: "...", parameters: { /* same */ }, handler: async ({ date, partySize }) => { const dateIso = resolveDate(date) // "friday" → "2026-05-15" const taken = await sql<{ time: string }[]>` SELECT time FROM reservations WHERE date = ${dateIso} AND status = 'confirmed' ` const allSlots = [ "18:00", "18:30", "19:00", "19:30", "20:00", "20:30", "21:00", "21:30", "22:00", "22:30", ] const capacity = await sql`SELECT max_concurrent FROM restaurant_config LIMIT 1` const free = allSlots.filter((slot) => { const concurrent = taken.filter(t => t.time === slot).length return concurrent < capacity[0].max_concurrent }) return { available: free.length > 0, slots: free.slice(0, 4), date: dateIso, partySize } }, }) ``` ### Step 3: send confirmation message After creating a reservation, send a follow-up WhatsApp confirming the booking. The auto-provisioned `ZAVU_API_KEY` already lets you do this: ```ts theme={null} import Zavudev from "@zavudev/sdk" const zavu = new Zavudev({ apiKey: process.env.ZAVU_API_KEY!, baseURL: process.env.ZAVU_API_BASE_URL, }) // inside create_reservation handler, after the INSERT: await zavu.messages.send({ to: ctx.contactPhone!, channel: "whatsapp", "Zavu-Sender": process.env.SENDER_ID!, text: `✅ Confirmado: ${customerName}, ${partySize} personas, ${date} a las ${time}. ID: RES-${row.id} Si necesitas cancelar, escribime "cancelar RES-${row.id}".`, }) ``` ### Step 4: cancellation tool ```ts theme={null} defineTool({ name: "cancel_reservation", description: "Cancel a confirmed reservation. Customer must provide the reservation ID.", parameters: { type: "object", properties: { reservationId: { type: "string" } }, required: ["reservationId"], }, handler: async ({ reservationId }, ctx) => { const id = reservationId.replace(/^RES-/, "") const result = await sql` UPDATE reservations SET status = 'cancelled', cancelled_at = NOW() WHERE id = ${id} AND phone = ${ctx?.contactPhone ?? null} RETURNING customer_name ` if (result.length === 0) { return { error: "not_found_or_not_yours", message: "No encuentro esa reserva con tu número.", } } return { cancelled: true, summary: `Cancelada la reserva ${reservationId} de ${result[0].customer_name}.`, } }, }) ``` Note the `WHERE phone = ${ctx?.contactPhone}` — customers can only cancel their own reservations, even if they get the right ID. ### Step 5: opening hours guard Add reasoning the LLM can't accidentally bypass: ```ts theme={null} defineTool({ name: "check_availability", description: "...", handler: async ({ date, partySize }) => { const dateIso = resolveDate(date) const day = new Date(dateIso).getDay() if (day === 1) { return { available: false, message: "Cerrado los lunes. Te puedo reservar para martes en adelante.", } } // ... }, }) ``` The LLM reads the `message` field and incorporates it into a natural-sounding reply. No need to add "and we're closed Mondays" to the prompt — the tool itself enforces it. ## Iterating fast ```sh theme={null} # 1. Edit index.ts. # 2. Push code to the dashboard without redeploying the runtime: npx zavudev fn push # 3. The dashboard reflects the new code instantly (good for code review). # 4. When ready to test live: npx zavudev deploy ``` When you remove a tool from the code, `npx zavudev deploy` deletes it from the agent automatically. The summary will show: ``` Tools synced: - cancel_reservation ``` ## Costs For a busy restaurant doing \~50 customer conversations a day: | Item | Per conversation | Per month | | - | - | - | | LLM (gpt-5.6-luna via Zavu gateway, \~3 turns) | \~\$0.001 | \~\$1.50 | | Function invocations | \~\$0.00000001 × 6 invocations | negligible | | WhatsApp conversation fee | \~\$0.005 (utility tier) | \~\$7.50 | | **Total** | **\~\$0.006** | **\~\$9** | Add your DB hosting (PlanetScale free tier works) and you're under \$10/mo for a fully automated booking agent. ## Next > [card] [Customer support example](/guides/functions/examples/customer-support) > > Knowledge base lookup + ticket creation. > [card] [Ecommerce example](/guides/functions/examples/ecommerce) > > Order tracking + smart recommendations. # Factory agents Source: https://docs.zavu.dev/guides/functions/factory-agents Pull a ready-made voice agent into your codebase and ship it in minutes. ## Factory agents Factory agents are ready-made agents you pull straight into your project with one command, then deploy. Under the hood each is a [Zavu Function](/guides/functions/overview) that declares an agent with `defineAgent` and its skills with `defineTool` — so what you pull is real, editable code you own, not a black box. `npx zavudev agents pull fermi` scaffolds the agent, registers it, and leaves you a directory to `npx zavudev deploy`. Voice agents answer phone calls; text agents run on WhatsApp, SMS, and more. > [!NOTE] > > You'll need an active **sender** in your Zavu project. For voice, the sender > also needs a **phone number** ([purchase one](/guides/phone-numbers/purchasing)). ## 1. Install the CLI ```sh npx (recommended) theme={null} # No install step. Always runs the current version. npx zavudev@latest --version ``` ```sh Global install theme={null} npm install -g zavudev ``` ```sh Standalone binary theme={null} # Download from https://github.com/zavudev/zavu-cli/releases, then: chmod +x ./zavu-macos-arm64 sudo mv ./zavu-macos-arm64 /usr/local/bin/zavudev ``` ```sh theme={null} npx zavudev login npx zavudev whoami # confirm the project ``` ## 2. Browse the catalog ```sh theme={null} npx zavudev agents catalog ``` ``` id name voice tools category fermi Fermi — Lead Qualification yes 1 sales support Support — Customer Support yes 2 support ``` ## 3. Find your sender ```sh theme={null} npx zavudev senders list export SENDER_ID="jn76vnxet8g5nq661by3v06y1581bmmn" ``` No sender yet? Create one — a phone number gives you voice and SMS right away: ```sh theme={null} npx zavudev phone-numbers search --country US npx zavudev phone-numbers buy +1XXXXXXXXXX npx zavudev senders create --name "My agent" --phone +1XXXXXXXXXX --set-default ``` > [!TIP] > > `npx zavudev agents init` does all of this in one guided command: it creates the sender > (buying a number if you want), pulls a factory agent, and sets `SENDER_ID` — so > you can skip straight to `npx zavudev deploy`. ### Connect more channels (from the CLI) Voice and SMS work as soon as the sender has a number. The rich channels each connect with one command: ```sh theme={null} # Telegram — paste a bot token from @BotFather npx zavudev telegram connect --sender "$SENDER_ID" --token # Email — add a domain, publish the DNS records it prints, then verify npx zavudev email-domains add example.com npx zavudev email-domains verify ``` The agent answers on the channels its own `channels` list names, and only those. Connecting a channel to the sender is necessary and not sufficient: a channel the sender receives on but the agent does not list is dropped before the agent sees it, with no error and no reply. It is recorded: the drop lands in `GET /v1/senders/{senderId}/agent/executions` with `status: "filtered"` and a line saying which channel arrived and which the agent listens on, and the dashboard's **Agents → Logs → Filtered** view shows the same. The factory agents ship `channels: ["*"]` — every channel their sender carries — so this only bites once you narrow it. Check the `channels` line in the file you pulled before you go looking for a bug in Telegram. ## 4. Pull the agent ```sh theme={null} npx zavudev agents pull fermi --sender "$SENDER_ID" cd fermi ``` This scaffolds an editable `index.ts`: ```ts theme={null} import { defineAgent, defineTool } from "@zavudev/functions" defineAgent({ senderId: process.env.SENDER_ID!, name: "Fermi", provider: "zavu", model: "openai/gpt-5.6-luna", channels: ["voice", "whatsapp"], voice: { enabled: true, model: "openai/gpt-4o", // co-located voice model — lowest latency greeting: "Hi, I'm Fermi. Tell me a bit about what you want to build.", language: "en", interruptible: true, maxCallDurationMinutes: 10, }, prompt: `# Personality You are Fermi, a sharp, friendly lead-qualification agent…`, }) defineTool({ name: "qualify_lead", description: "Record a qualified lead once you know the use case, channels, and volume.", parameters: { type: "object", properties: { /* … */ }, required: ["use_case", "monthly_volume", "score"] }, handler: async (args) => { // TODO: push this lead to your CRM / Slack / webhook. return { ok: true } }, }) ``` > [!TIP] > > `--sender` sets the `SENDER_ID` secret for you. If you leave it off, set it > before deploying with `npx zavudev fn secrets set SENDER_ID `. > [!NOTE] > > The `voice` block is what makes this agent answer calls. It runs the LLM > **co-located in the voice network** for the lowest latency, independent of the > `model` used for text. Remove the `voice` block and redeploy to turn the agent > back into a text-only agent. ## 5. Deploy ```sh theme={null} npx zavudev deploy ``` ``` ✓ Deployed in 12s Agents synced: + Fermi Tools synced: + qualify_lead ``` Your agent is live. For a voice agent, the sender's number now answers calls with Fermi; for a text agent, every inbound message hands off to it. ## 6. Call it Dial the sender's phone number from your phone. Fermi greets you, qualifies the lead, and calls `qualify_lead` near the end of the conversation. Prefer to test outbound? Place a call from code: ```sh theme={null} npx zavudev calls create --to "+15551234567" --sender "$SENDER_ID" ``` ## 7. Watch it run ```sh theme={null} npx zavudev fn logs --tail # live tool calls (your handler activity) npx zavudev agents executions --sender "$SENDER_ID" # tools used, tokens, cost, latency npx zavudev calls list --limit 10 # every call in & out ``` ## 8. Iterate Edit `index.ts` — tweak the prompt, add a `defineTool`, change the greeting — and redeploy: ```sh theme={null} npx zavudev deploy ``` The summary shows only what changed. There's no separate agent config to keep in sync: the code is the source of truth. ## Common pitfalls > [accordion] The agent doesn't answer calls > > Confirm the sender has a phone number assigned. > Then check the deploy printed `Agents synced: + Fermi` and that the `voice` > block has `enabled: true`. > [accordion] `voice config ignored` warning on deploy > > A field in your `voice` block is out of range (e.g. > `maxCallDurationMinutes` above the cap) or misspelled. The deploy warns and > ships the agent as text-only rather than failing. Fix the value and > redeploy. > [accordion] A skill never gets called > > The LLM decides when to call a tool from its `description`. Rewrite each > `description` as the answer to "when should the model call this?" — vague > descriptions don't trigger. ## Next steps > [card] [defineAgent in depth](/guides/functions/defining-agents) > > Providers, models, prompts, and the voice block. > [card] [defineTool in depth](/guides/functions/defining-tools) > > Schemas, handlers, returning structured data. > [card] [Voice configuration](/guides/voice-agents/configuration) > > Greetings, languages, voicemail, and transfer. > [card] [Secrets](/guides/functions/secrets) > > Store SENDER\_ID, API keys, and other env vars. # Zavu Functions Source: https://docs.zavu.dev/guides/functions/overview Deploy serverless code that powers your agents — no infra to manage, just `npx zavudev deploy`. ## Zavu Functions Zavu Functions let you run TypeScript in our cloud and wire it directly to your WhatsApp/SMS/Email senders. You write the code locally, run `npx zavudev deploy`, and seconds later your agent is live with tools backed by real business logic. ```ts theme={null} import { defineAgent, defineTool } from "@zavudev/functions" defineAgent({ senderId: process.env.SENDER_ID!, name: "Bella", provider: "zavu", model: "openai/gpt-5.6-luna", prompt: "Eres Bella, anfitriona de la Pizzeria. Sé breve.", channels: ["whatsapp"], }) defineTool({ name: "check_availability", description: "Get free reservation slots for a date and party size.", parameters: { type: "object", properties: { date: { type: "string" }, partySize: { type: "number" }, }, required: ["date", "partySize"], }, handler: async ({ date, partySize }) => { // your real booking logic — DB, POS, external API, whatever return { available: true, slots: ["19:00", "21:00"] } }, }) ``` That's the entire integration. One file. `npx zavudev deploy`. Done. ## When to use Functions > [card] AI agent with real actions > > Customer asks something, the LLM decides which tool to call, your code runs, > real data goes back as the answer. > [card] Custom webhooks > > Receive inbound from Stripe, GitHub, your own systems, then forward as a > WhatsApp message. > [card] Scheduled jobs > > Daily report digests, reminder broadcasts, cleanup tasks. > [card] Event-driven automation > > React to `message.inbound`, `broadcast.status_changed`, etc. with custom logic > without managing webhook receivers yourself. ## How it relates to AI Agents Zavu has two complementary ways to build agents: | You want | Use | | - | - | | A no-code agent configured from the dashboard | [AI Agents](/guides/ai-agents/overview) | | An agent with custom business logic in your own code | **Zavu Functions** (this guide) | | Both — start no-code, evolve to code | Start with AI Agents, migrate when you need tools or custom flows | Functions can *fully manage* an AI Agent: the `defineAgent` call creates and keeps the agent config in sync with your code. Once your function declares `defineAgent`, the agent is "managed by the function" — the dashboard surfaces this and disables manual edits to prevent drift. ## Mental model ``` +----------------+ +--------------------+ +-----------+ | WhatsApp user | -----> | Zavu sender (WABA) | -----> | AI Agent | +----------------+ +--------------------+ +-----------+ | (tool call) v +-------------------------------+ | Your Zavu Function | | defineTool(handler) | +-------------------------------+ | (return result) v Agent answers user ``` * **Fully managed runtime.** Each function runs in Zavu's serverless cloud. No consoles to log into — `npx zavudev deploy` handles bundling, dependencies, and publishing. * **Internal invocation.** When the agent calls a tool, we use signed internal invocations — your function is **not** publicly exposed. No HTTP, no HMAC secrets to rotate, no DDoS surface. * **Native event binding.** Functions can also subscribe to Zavu events (`message.inbound`, `broadcast.status_changed`, etc) via triggers. * **Auto-provisioned credentials.** Every function gets a scoped `ZAVU_API_KEY` in its environment so it can call our SDK without you handling key distribution. ## Quick tour > [!TIP] > > Want your AI coding agent (Claude Code, Cursor, Copilot…) to write the > function for you? Install [Zavu's Coding Agent Skills](/tools/coding-agent-skills) — > the `functions` skill teaches it `defineAgent`, `defineTool`, `npx zavudev deploy`, > and the full debug flow. > > ```sh theme={null} > npx skills add zavudev/zavu-skills > ``` **1. Install the CLI** ```sh theme={null} npx zavudev@latest --version # or install it globally: npm install -g zavudev # or grab a standalone binary from # https://github.com/zavudev/zavu-cli/releases ``` **2. Login (one time)** ```sh theme={null} npx zavudev login ``` Opens your browser, lets you pick a project. The API key is saved to `~/.zavu/credentials.json` and used by every later command. **3. Scaffold a function** ```sh theme={null} npx zavudev fn init --template restaurant-booking -y cd reservations ``` Creates `index.ts` with `defineAgent` + `defineTool` boilerplate. **4. Set secrets** ```sh theme={null} npx zavudev fn secrets set SENDER_ID jn76vnxet8g5nq661by3v06y1581bmmn ``` The agent will run on this WhatsApp sender. Get the ID from `npx zavudev senders list`. **5. Deploy** ```sh theme={null} npx zavudev deploy ``` Bundle, upload, agent + tools synced to your sender. **6. Try it** Send a WhatsApp to the sender's number. The agent answers, calls your tool handlers, returns real data. ## Continue > [card] [Quickstart](/guides/functions/quickstart) > > Full step-by-step from zero to live agent in 10 minutes. > [card] [CLI reference](/guides/functions/cli) > > Every `zavu` command, flag by flag. > [card] [Define an agent](/guides/functions/defining-agents) > > `defineAgent` API, providers, prompts, models. > [card] [Define tools](/guides/functions/defining-tools) > > `defineTool` API, schemas, handlers, error handling. > [card] [Secrets](/guides/functions/secrets) > > Environment variables encrypted at rest. > [card] [Triggers](/guides/functions/triggers) > > Listen to Zavu events from your function. > [card] [Runtime versions](/guides/functions/runtime) > > How pinning works, when to upgrade. > [card] [Examples](/guides/functions/examples/restaurant) > > Complete restaurant booking agent, walked through. # Quickstart Source: https://docs.zavu.dev/guides/functions/quickstart Build a working WhatsApp agent in 10 minutes. ## Quickstart We'll build a restaurant booking agent that lives on a WhatsApp sender. By the end, customers can text the number and the agent will show the menu, check availability, and confirm reservations. > [!NOTE] > > You'll need an active **WhatsApp sender** in your Zavu project. If you don't > have one yet, [connect WhatsApp](/guides/whatsapp/connect-whatsapp) first. > [!TIP] > > **Skip writing boilerplate**: install [Zavu's Coding Agent Skills](/tools/coding-agent-skills) > in Claude Code, Cursor, Copilot, or any of 40+ supported AI coding agents. > Your agent will then know `defineAgent`, `defineTool`, `npx zavudev deploy`, and > everything in this guide — just describe what you want and it generates > the code for you. > > ```sh theme={null} > npx skills add zavudev/zavu-skills > ``` ## 1. Install the CLI ```sh npx (recommended) theme={null} # No install step. Always runs the current version. npx zavudev@latest --version ``` ```sh Global install theme={null} npm install -g zavudev zavudev --version ``` ```sh Standalone binary theme={null} # Download the binary for your platform from # https://github.com/zavudev/zavu-cli/releases # Then: chmod +x ./zavu-macos-arm64 sudo mv ./zavu-macos-arm64 /usr/local/bin/zavudev ``` `npx zavudev@latest` resolves the newest release every time, so there is nothing to upgrade. If you installed globally, update with `npm install -g zavudev@latest`. > [!NOTE] > > The Homebrew tap is deprecated and no longer updated. If you installed with > `brew install zavudev/tools/zavu`, remove it and use one of the methods above: > > ```sh theme={null} > brew uninstall zavu > ``` ## 2. Log in ```sh theme={null} npx zavudev login ``` This opens your browser, you sign in, pick the project this agent will live in, and click **Authorize**. The CLI saves the API key to `~/.zavu/credentials.json` (chmod 0600). Confirm you're on the right project: ```sh theme={null} npx zavudev whoami ``` ``` Project: Acme Restaurants Project ID: jh72w2dnzytttrxjqjtaq267nn7wcw6y Team: Acme Mode: live API URL: https://api.zavu.dev Key ending: …1bmmn ``` ## 3. Find your sender ```sh theme={null} npx zavudev senders list ``` Copy the ID of the WhatsApp sender you'll attach the agent to: ``` id name phone whatsapp jn76vnxet8g5nq661by3v06y1581bmmn Pizzeria Main +15076323077 yes ``` ```sh theme={null} export SENDER_ID="jn76vnxet8g5nq661by3v06y1581bmmn" ``` ## 4. Scaffold the function ```sh theme={null} npx zavudev fn init --template restaurant-booking -y cd reservations ``` You'll get an `index.ts` like this (truncated): ```ts theme={null} import { defineAgent, defineTool } from "@zavudev/functions" defineAgent({ senderId: process.env.SENDER_ID!, name: "Bella", provider: "zavu", model: "openai/gpt-5.6-luna", channels: ["whatsapp"], prompt: `Eres Bella, anfitriona de Bella Pizzeria…`, }) defineTool({ name: "view_menu", description: "Get the restaurant menu.", parameters: { type: "object", properties: { filter: { type: "string" } } }, handler: async (args) => ({ menu: [/* … */] }), }) // + check_availability, create_reservation, view_reservation ``` > [!TIP] > > The template uses `provider: "zavu"` — our managed AI gateway. No BYOK required; > LLM costs are billed from your Zavu balance. ## 5. Set the sender ID as a secret ```sh theme={null} npx zavudev fn secrets set SENDER_ID "$SENDER_ID" ``` Output: ``` ✓ Created SENDER_ID (…1bmmn) Environment updates on the next `npx zavudev deploy`. Run it now to apply. ``` ## 6. Deploy ```sh theme={null} npx zavudev deploy ``` Watch the output: ``` › Deploying Restaurant reservations agent (reservations)… deployment id: nx7ztwe6cwg1dfp14bk6v7nb2s86hpn6 › status: bundling › status: uploading › status: publishing › status: active ✓ Deployed in 14s Agents synced: + Bella Tools synced: + view_menu + check_availability + create_reservation + view_reservation ``` Your agent is live. The WhatsApp sender will now hand off every inbound to it. ### How the sender → function link works You didn't run `npx zavudev fn triggers add` anywhere — and yet, the sender knows to forward every inbound to your function. Here's the wiring: * `defineAgent({ senderId, ... })` registers your agent **on that sender**. When `npx zavudev deploy` syncs the manifest, Zavu writes a row that says *"sender `$SENDER_ID` has an active agent backed by function `reservations`."* * Every inbound message to that sender automatically hands off to the agent, which runs your tools (`view_menu`, `check_availability`, etc.) inside the function. * You can verify the link any time: ```sh theme={null} npx zavudev agents get --sender "$SENDER_ID" # → enabled: true, managedByFunctionId: fn_…, model: openai/gpt-5.6-luna ``` > [!NOTE] > > **No `defineAgent`? You need triggers.** If you want a function to react to > sender events *without* an LLM agent — say, a webhook that logs every > delivery, or a custom non-LLM responder — declare it with `defineFunction` > and bind it explicitly: > > ```sh theme={null} > npx zavudev fn triggers add --events message.inbound --senders "$SENDER_ID" > ``` > > See the [Triggers guide](/guides/functions/triggers) for the full event > list and cartesian-binding patterns (multiple events × multiple senders). ## 7. Try it on WhatsApp From your phone (not the sender's number), send the sender: > hola, qué tienen vegano? Expected flow: ``` 👤 hola, qué tienen vegano? 🤖 [calls view_menu(filter="vegan")] 🤖 Tenemos Vegan Buddha Bowl ($11). ¿Te lo reservo? 👤 sí, mañana viernes a las 9 para 2 🤖 [calls check_availability(date="friday", partySize=2)] 🤖 Tengo 19:30 y 21:00. ¿Cuál preferís? 👤 21 🤖 [calls create_reservation(date="friday", time="21:00", partySize=2, customerName="…")] 🤖 ¡Listo! Reserva RES-LXXY confirmada para 2 personas el viernes a las 21:00. ``` ## 8. Watch it run In three terminals: ```sh theme={null} # Live tool calls (your function's console.log + handler activity) npx zavudev fn logs --tail ``` ```sh theme={null} # Agent executions: which tools, tokens used, cost, latency npx zavudev agents executions --sender "$SENDER_ID" ``` ```sh theme={null} # Every message in & out npx zavudev messages list --limit 10 ``` ## 9. Iterate Edit `index.ts` — say, add a `cancel_reservation` tool — and redeploy: ```sh theme={null} npx zavudev deploy ``` The summary shows what changed: ``` ✓ Deployed in 11s Tools synced: + cancel_reservation ``` The new tool is immediately available to the agent on the next user message. You don't need to update prompts — the LLM reads the tool's `description` and `parameters` schema directly. ## Common pitfalls > [accordion] The agent doesn't respond on WhatsApp > > Run `npx zavudev agents get --sender "$SENDER_ID"` and confirm `enabled: true`. > If false, check that `defineAgent` is being called (deploy must show > `Agents synced: + Bella`). > [accordion] Tools never get called > > Check that the tool `description` is specific enough. The LLM uses the > description to decide when to call the tool, so vague descriptions > (`"do stuff"`) don't trigger. Rewrite each `description` as the answer to > *"when should the model call this?"*. > > Also confirm `npx zavudev agents tools list --sender "$SENDER_ID"` shows the 4 > tools with `enabled: true`. > [accordion] A tool errors out > > Watch `npx zavudev fn logs --tail` while you trigger the tool. The error stack > appears live. Common causes: missing env var (run > `npx zavudev fn secrets list` to confirm what's set), JSON parse errors on > response, unhandled async exceptions. > [accordion] My slug is too long error > > Function names cap at 64 chars internally, and we prefix yours with > `zavu-fn--` (41 chars used). Slugs over 23 chars get rejected > server-side with a clear message. Pick something short — `bella`, not > `restaurant-reservations-agent-v2`. > [accordion] I'm using defineFunction (no agent) and nothing fires > > Pure `defineFunction` handlers don't get traffic automatically — they > need an explicit trigger. Run: > > ```sh theme={null} > npx zavudev fn triggers list # see what's bound > npx zavudev fn triggers add --events message.inbound \ > --senders "$SENDER_ID" # bind to a sender > ``` > > `defineAgent` is the only declarative shortcut that auto-binds — every > other event flow goes through triggers. See [Triggers](/guides/functions/triggers). ## Next steps > [card] [Define agents in depth](/guides/functions/defining-agents) > > Providers, models, prompts, triggers. > [card] [Define tools in depth](/guides/functions/defining-tools) > > Schemas, handlers, returning structured data. > [card] [Customer support example](/guides/functions/examples/customer-support) > > Knowledge base lookup + ticket creation. > [card] [Ecommerce example](/guides/functions/examples/ecommerce) > > Order status + smart recommendations. # Runtime versions Source: https://docs.zavu.dev/guides/functions/runtime How runtime pinning works and when to upgrade. ## Runtime versions Each Zavu Function is built against a specific **runtime version** — a sealed bundle containing `@zavudev/functions`, our SDK, and a small set of curated libraries. Runtime versions are immutable: once a function is deployed against runtime `v7`, it keeps using that exact version forever, even if we publish newer ones. This means your function is **stable**. We can iterate the runtime without breaking what's already live. Throughout this page `v7` and `v8` are shorthand. The identifier the API returns is the runtime layer's ARN, whose trailing number is that version. ## How pinning works ``` +-----------------------+ +---------------------------+ | You: npx zavudev deploy | -----> | Function pinned to v7 | +-----------------------+ +---------------------------+ ^ | (forever) +-----------------------+ | Zavu: publishes v8 | +-----------------------+ # Your function is still on v7. Unaffected. +--------------------------------+ +--------------------------+ | You: npx zavudev deploy --update-runtime| ---->| Function now pinned to v8| +--------------------------------+ +--------------------------+ ``` * **First deploy**: function is pinned to whatever the latest runtime version was at deploy time. * **Subsequent deploys** (`npx zavudev deploy`): keep the same pin. The runtime doesn't change even if newer versions are available. * **Opt-in upgrade** (`npx zavudev deploy --update-runtime`): bump to the latest. ## Checking your pin ```sh theme={null} curl https://api.zavu.dev/v1/functions/$FN_ID \ -H "Authorization: Bearer $KEY" \ | jq '.function.runtimeLayerArn' ``` ``` "arn:aws:lambda:us-east-1:000000000000:layer:zavu-nodejs24:7" ``` The pin is the runtime layer the function is bound to. The same value across deploys means the same runtime; `null` means the function has never deployed. ## Auditing per-deployment Every deployment record stores which runtime ran: ```sh theme={null} curl https://api.zavu.dev/v1/functions/deployments/$DEPLOYMENT_ID \ -H "Authorization: Bearer $KEY" \ | jq '.deployment | {version, runtimeLayerArn, deployedAt}' ``` ```json theme={null} { "version": 4, "runtimeLayerArn": "arn:aws:lambda:us-east-1:000000000000:layer:zavu-nodejs24:7", "deployedAt": "2026-04-12T14:23:00.000Z" } ``` Useful for "which deploy was on which runtime when the bug happened?". ## Upgrading ```sh theme={null} npx zavudev deploy --update-runtime ``` This deploys the current source code AGAINST the latest runtime version. If the latest is `v8` and you were pinned to `v7`: * The function's runtime pin moves to `v8`. * All subsequent `npx zavudev deploy` (without the flag) keep you on `v8`. ## When you should upgrade Most of the time, never. Stay pinned. Upgrade only when: | Reason | Action | | - | - | | **Security advisory** for your current runtime | Upgrade immediately. We'll email you. | | **New feature** you want (`defineFlow`, new SDK methods, etc.) | Upgrade when convenient. | | **Curated dep version bump** that fixes a bug affecting you | Upgrade. | | **Performance improvements** in cold-start, bundle size | Upgrade when convenient. | | Nothing changed and the new version is just a refactor | Don't bother. | ## When you should NOT upgrade * **Breaking change in the framework** that touches a method you use. Read the changelog first. Upgrade after fixing your code. * **Dependency bump** in a curated lib (zod, hono, etc) — if your code depends on specific behavior, test first. ## Support policy There is one current runtime layer at a time. Older layers are not tracked with a lifecycle status, and no deploy is ever refused because its pin is old. > [!NOTE] > > Deployed functions keep their pinned runtime indefinitely. A newer layer being > published does not move, patch or retire a function already pinned to an older > one — only a deploy with `--update-runtime` changes the pin. ## Bulk upgrade When a security patch lands, you can sweep all your functions: ```sh theme={null} # List all (we'll add `--outdated` filter soon; for now use the API) npx zavudev senders list # to find sender IDs npx zavudev agents executions # for activity insight # Per function: cd ./my-function npx zavudev deploy --update-runtime ``` For programmatic bulk migration: ```sh theme={null} for fn_id in $(curl -s https://api.zavu.dev/v1/functions \ -H "Authorization: Bearer $KEY" \ | jq -r '.items[].id'); do curl -X POST "https://api.zavu.dev/v1/functions/$fn_id/deploy" \ -H "Authorization: Bearer $KEY" \ -d '{"updateRuntime":true}' done ``` ## What's actually in a runtime version The current runtime (\~3 MB) contains: | Package | Version range | Purpose | | - | - | - | | `@zavudev/functions` | platform-bundled | `defineFunction`, `defineAgent`, `defineTool`, `verifyWebhook` | | `@zavudev/sdk` | latest stable | Call Zavu APIs | | `zod` | ^3.22 | Schema validation | | `hono` | ^4 | HTTP-friendly utilities | | `dayjs` | ^1 | Date manipulation | | Node built-ins | — | `fetch`, `crypto`, `Buffer`, etc. | If you `import` one of these in your function source code, esbuild marks it as external and the runtime resolves it at execution time — no `npm install` needed, no bundle bloat. To use a package NOT in the bundled set, declare it in `package.json` and the build worker installs it during `npx zavudev deploy`: ```json theme={null} { "dependencies": { "openai": "^4.20.0" } } ``` ## CPU architecture Each function runs on **arm64** by default (AWS Graviton). You can opt into **x86\_64** when an npm dependency requires it: | Architecture | Compute cost | When to use | | - | - | - | | `arm64` (default) | 1.00× | All pure-TypeScript code and any native module with modern arm64 prebuilds. | | `x86_64` | 1.25× | Some image / crypto / wasm libs only publish x86 prebuilts. Bumps every invocation by 25% units. | ### Setting it Pick whichever fits your workflow — they compose, with CLI flag > package.json > server default. ```json package.json theme={null} { "name": "my-function", "zavu": { "architecture": "x86_64" }, "dependencies": { "sharp": "^0.33.0" } } ``` ```sh CLI flag theme={null} npx zavudev deploy --arch x86_64 ``` ```text Dashboard theme={null} Function detail → Settings → CPU architecture → x86_64 ``` The change applies on the next `npx zavudev deploy` — Lambda doesn't allow swapping architecture in-place, so a new function deployment is required. ### Pricing impact The architecture multiplier compounds with the memory multiplier. Examples: | Memory | arm64 units / call | x86\_64 units / call | | - | - | - | | 128 MB | 1.00 | 1.25 | | 256 MB | 2.00 | 2.50 | | 512 MB | 4.00 | 5.00 | | 1024 MB | 8.00 | 10.00 | If you're approaching your plan's monthly quota, prefer arm64 first — switch to x86 only when you've confirmed a specific dep needs it. See [Pricing model](/concepts/functions#pricing-model) for plan quotas. ### When you actually need x86 Modern packages (most of npm) ship arm64 prebuilts. You generally only need x86 for: * Older versions of native libs (`bcrypt < 5`, very old `sharp`, `canvas`) * Wasm-wrapped tools without arm64 bindings * Code that uses `process.arch === "x64"` assumptions A `npx zavudev deploy` on arm64 with an incompatible package usually fails with a clear "Could not find prebuild for ... arm64" error — that's your cue to switch. ## Rolling back We don't have a `--runtime ` flag yet for explicit version pinning. If an upgrade breaks you, the workaround is to deploy from a previous source commit **without** `--update-runtime`: ```sh theme={null} git checkout npx zavudev deploy # uses the pinned runtime, no flag → keeps current pin ``` If the pin is already on the broken version, the most reliable rollback is to delete and recreate the function with the previous runtime explicitly provisioned by Zavu support. > [!TIP] > > This is rare in practice — runtime upgrades are tested in our staging > environment before public release. But if you hit it, contact support and > we'll help with the manual repin. # Function Secrets & Environment Variables Source: https://docs.zavu.dev/guides/functions/secrets Inject env vars into your Zavu function — API keys, DB URLs, anything sensitive. Encrypted at rest, scoped per function, rotatable from the dashboard. ## Secrets Function secrets are encrypted at rest and injected as environment variables at runtime. The function reads them via `process.env.` just like in any Node program. ```sh theme={null} npx zavudev fn secrets set SENDER_ID jn76vnxet8g5nq661by3v06y1581bmmn npx zavudev fn secrets set DATABASE_URL postgresql://... npx zavudev fn secrets set OPENAI_API_KEY sk-... ``` ```ts theme={null} defineAgent({ senderId: process.env.SENDER_ID!, ... }) defineTool({ ..., handler: async () => { const db = postgres(process.env.DATABASE_URL!) // ... } }) ``` ## Setting secrets ### Inline ```sh theme={null} npx zavudev fn secrets set OPENAI_API_KEY sk-abc123 ``` The value is in your shell history — fine for non-secret config, not great for real keys. ### From a file ```sh theme={null} npx zavudev fn secrets set GOOGLE_SERVICE_ACCOUNT --from-file ./sa.json ``` Reads the file as UTF-8. Trailing whitespace is stripped. Useful for multi-line values (PEM keys, JSON service accounts). ### From stdin (safest) ```sh theme={null} pbpaste | npx zavudev fn secrets set OPENAI_API_KEY - # or echo "$SECRET_FROM_VAULT" | npx zavudev fn secrets set KEY - ``` Nothing touches disk or shell history. The `-` placeholder tells the CLI to read from stdin until EOF. ## Listing ```sh theme={null} npx zavudev fn secrets list ``` ``` key value synced ───────────── ──────── ────── SENDER_ID …1bmmn yes DATABASE_URL …5432 yes OPENAI_API_KEY …sk-x no 1 secret not yet deployed — run `npx zavudev deploy` to sync. ``` Only the last 4 characters of each value are shown. **Plaintext is never returned by the API** — even from the dashboard, only the same last-4 is visible. ## Removing ```sh theme={null} npx zavudev fn secrets unset OLD_KEY # alias npx zavudev fn secrets rm OLD_KEY ``` ## When changes take effect Setting / unsetting a secret marks the function as **out of sync**. The next `npx zavudev deploy` rebuilds the function with the new env vars. Until then, the running function still has the old environment. ``` $ npx zavudev fn secrets set NEW_KEY value ✓ Created NEW_KEY (…alue) Environment updates on the next `npx zavudev deploy`. Run it now to apply. $ npx zavudev deploy ... function now sees process.env.NEW_KEY ``` > [!TIP] > > You can batch secret changes before a single deploy. Setting 5 secrets in a > row results in 1 sync (the next deploy), not 5. ## Constraints | Constraint | Limit | | - | - | | Key format | `[A-Z_][A-Z0-9_]*` (uppercase env-var style) | | Key length | ≤ 64 chars | | Reserved prefixes | `AWS_`, `LAMBDA_`, `_HANDLER`, `_X_AMZN` (system-reserved) | | Value size | ≤ 4096 chars | | Total secrets per function | 50 | | Total env size | 4 KB (runtime hard limit) | For values larger than 4 KB (large JSON blobs, certificates), upload to S3 / Convex storage and store a URL + auth header pair instead. ## Auto-provisioned secrets Every function created by `npx zavudev fn init` gets these injected automatically — you don't set them yourself: | Key | Value | Purpose | | - | - | - | | `ZAVU_API_KEY` | A unique live API key scoped to this function's project | Lets the function call Zavu's REST API. | | `ZAVU_API_BASE_URL` | The dashboard's Convex `.site` URL | So local-dev functions hit the right backend. | | `ZAVU_PROJECT_ID` | The function's project ID | For logging / multi-tenant code. | | `ZAVU_FUNCTION_ID` | This function's ID | For logging. | | `ZAVU_FUNCTION_SLUG` | This function's slug | For logging / URL construction. | Use them directly: ```ts theme={null} import Zavudev from "@zavudev/sdk" const zavu = new Zavudev({ apiKey: process.env.ZAVU_API_KEY!, baseURL: process.env.ZAVU_API_BASE_URL, }) ``` > [!NOTE] > > `ZAVU_API_KEY` is revoked automatically when you `npx zavudev fn delete` the > function. If you reset it manually from the dashboard's API Keys page, the > function will start failing — redeploy to provision a new one. > > The auto-key has `messages:send`, `messages:read`, `contacts:read` scopes. > For other operations create a separate scoped key and inject it as a secret. ## Encryption Values are encrypted with AES-256-GCM, key derived via PBKDF2 (100,000 iterations, SHA-256) from the platform encryption key. Encryption happens server-side before the value is persisted, so plaintext never lives in our database. Your function receives the value at deploy time as a standard environment variable, encrypted at rest by managed encryption keys. Inside the function, `process.env.X` returns the value. ## Common patterns > [accordion] Rotating an API key > > ```sh theme={null} > npx zavudev fn secrets set OPENAI_API_KEY sk-new-value > npx zavudev deploy > # The old key is now overwritten in storage; revoke it in OpenAI's dashboard. > ``` > [accordion] Different secrets per environment > > Functions are project-scoped — if you have separate Zavu projects for > staging vs prod, each has its own secrets. > > ```sh theme={null} > npx zavudev login # pick staging project > npx zavudev fn secrets set DB_URL postgres://staging… > npx zavudev deploy > > npx zavudev login # pick prod project > npx zavudev fn secrets set DB_URL postgres://prod… > npx zavudev deploy > ``` > > `npx zavudev whoami` shows the current project before each operation. > [accordion] Secrets in source control (don't) > > Never commit secret values. Commit a `.zavu/secrets.example.yml` style > file with key names + descriptions, and have a teammate's setup script > prompt for actual values: > > ```sh theme={null} > # setup.sh > while read line; do > [[ -z "$line" ]] && continue > key=$(echo "$line" | cut -d= -f1) > desc=$(echo "$line" | cut -d= -f2-) > read -s -p "Enter $key ($desc): " val > echo > npx zavudev fn secrets set "$key" "$val" > done < secrets.spec > ``` > [accordion] CI / automation > > Use `ZAVUDEV_API_KEY` env var so the CLI uses your CI's key: > > ```yaml theme={null} > # .github/workflows/deploy.yml > env: > ZAVUDEV_API_KEY: ${{ secrets.ZAVU_CI_KEY }} > steps: > - run: | > npx zavudev fn secrets set DB_URL "${{ secrets.DB_URL }}" > npx zavudev fn secrets set API_KEY "${{ secrets.API_KEY }}" > npx zavudev deploy > ``` ## API equivalence For automation, the secret endpoints are part of the public API: ```sh theme={null} # Set curl -X PUT https://api.zavu.dev/v1/functions/$FN_ID/secrets/SENDER_ID \ -H "Authorization: Bearer $KEY" \ -d '{"value":"jn76…"}' # List (values never returned) curl https://api.zavu.dev/v1/functions/$FN_ID/secrets \ -H "Authorization: Bearer $KEY" # Unset curl -X DELETE https://api.zavu.dev/v1/functions/$FN_ID/secrets/OLD_KEY \ -H "Authorization: Bearer $KEY" ``` See the [API reference](/api-reference) for full schemas. # Triggers Source: https://docs.zavu.dev/guides/functions/triggers Bind your function to Zavu events — message.inbound, broadcast.status_changed, and more. ## Triggers A trigger says "fire this function when this event happens on this sender." Triggers are how you build event-driven automation **without** an AI agent — just raw events, your code reacts. ```sh theme={null} npx zavudev fn triggers add --events message.inbound --senders snd_abc ``` When a `message.inbound` event fires for sender `snd_abc`, the function runs. > [!TIP] > > If you only want a chatbot, you usually don't need triggers — `defineAgent` > already binds the agent to its sender. Use triggers when you want **raw > event delivery**: custom logic before/after the agent, broadcasts, system > events, or stand-alone automation without an LLM. ## Event types | Event | When it fires | Typical use | | - | - | - | | `message.queued` | Outbound message accepted for sending | Track outbound throughput | | `message.sent` | Provider accepted the outbound | Update CRM with provider IDs | | `message.delivered` | Recipient device received it | Conversion tracking | | `message.read` | Recipient opened it (WhatsApp only) | Engagement metrics | | `message.failed` | Outbound permanently failed | Retry queues, fallback channel | | `message.inbound` | Customer messaged us | Custom bots, escalation logic | | `message.unsupported` | Inbound message type we don't handle | Log + reply with a fallback | | `broadcast.status_changed` | Broadcast state moved | Pipeline orchestration | | `conversation.new` | First message ever from a contact | Welcome flows, onboarding | | `template.status_changed` | WhatsApp template approved/rejected | CI deploys, alerts | | `invitation.status_changed` | Partner invitation moved | Onboarding follow-up | | `cron` | On a schedule you define | Daily digests, syncs, cleanups | Run `npx zavudev fn triggers events` for the always-current list. ## Adding triggers ### Single event, single sender ```sh theme={null} npx zavudev fn triggers add \ --events message.inbound \ --senders jn76vnxet8g5nq661by3v06y1581bmmn ``` ### Single event, all senders in the project ```sh theme={null} npx zavudev fn triggers add --events message.inbound --senders any ``` ### Multiple events × multiple senders (cartesian) ```sh theme={null} npx zavudev fn triggers add \ --events message.delivered,message.read,message.failed \ --senders snd_abc,snd_xyz # → 6 triggers (3 events × 2 senders) ``` ### Mix specific + any ```sh theme={null} npx zavudev fn triggers add \ --events message.inbound \ --senders snd_abc,any # → 2 triggers: one for snd_abc, one for "any sender" ``` Duplicates are deduped silently. ### Schedules (cron) The `cron` event type runs the function on a schedule instead of a messaging event. Pass a standard 5-field expression (minute, hour, day-of-month, month, day-of-week), evaluated in **UTC**, minimum granularity one minute: ```sh theme={null} npx zavudev fn triggers add --events cron --cron '*/15 * * * *' # every 15 min npx zavudev fn triggers add --events cron --cron '0 9 * * 1-5' # weekdays 09:00 UTC ``` A schedule has no sender dimension, and one function can hold several cron triggers with different expressions. Each fire invokes the function with: ```json theme={null} { "type": "cron", "timestamp": 1736850000000, "data": { "cron": "*/15 * * * *" } } ``` Pausing a cron trigger (`fn triggers toggle --off`) stops the schedule; re-enabling restarts it from now — missed ticks are not replayed. ## Handling events in code When a trigger fires, your function's default handler (`defineFunction`) receives the event: ```ts theme={null} import { defineFunction } from "@zavudev/functions" export default defineFunction(async (event, ctx) => { // event.type is "message.inbound", "broadcast.status_changed", etc. // event.data has the payload (shape depends on event) // event.senderId is set for sender-scoped events // event.projectId, event.timestamp always present switch (event.type) { case "message.inbound": await handleInbound(event.data) break case "broadcast.status_changed": await handleBroadcastStatus(event.data) break } }) ``` The event shape mirrors the [webhook payload](/guides/receiving-messages/events) — same JSON, same fields. The difference: no HTTP receiver needed, no signature verification, no retries to manage. The dispatcher invokes your function directly with the event in the payload. ## Pausing without removing Toggle a trigger off without deleting: ```sh theme={null} npx zavudev fn triggers list # id event sender active # tr_abc message.inbound snd_xyz yes npx zavudev fn triggers toggle tr_abc --off # now active: no — function won't fire on this event npx zavudev fn triggers toggle tr_abc # re-enables ``` Useful for temporary disabling without losing the trigger configuration. ## Removing ```sh theme={null} npx zavudev fn triggers rm tr_abc ``` ## When triggers + defineAgent coexist If your function has BOTH: * `defineAgent({...})` running on a sender, AND * An explicit trigger for `message.inbound` on the same sender …then **two things happen** for every inbound message: 1. The agent processes the message and replies via tools. 2. Your `defineFunction` default handler also runs (the trigger fires it). This is by design — you might want raw event access (for logging, custom analytics, escalation logic) on top of the agent. Just make sure your raw handler doesn't *also* send a reply or you'll double-message the customer. To avoid duplicate processing, gate your raw handler: ```ts theme={null} export default defineFunction(async (event, ctx) => { if (event.type !== "message.inbound") return // Don't send a reply — the agent does that. Just observe. await analytics.track("inbound", { contactPhone: event.data.from, text: event.data.text, }) }) ``` ## Native vs HTTP webhooks Triggers are the **native** way to receive events. The old path — [webhooks on senders](/guides/receiving-messages/webhooks) — still works and is the right choice when your event receiver lives outside Zavu (an n8n flow, a Vercel function, an internal server). Inside Zavu Functions, prefer triggers: | Trait | Native triggers | Sender webhooks | | - | - | - | | Setup | `npx zavudev fn triggers add` | Configure URL + signing secret per sender | | Auth | Zavu-internal signed invocation | HMAC SHA-256 over the body | | Latency | \~50ms (direct invoke) | Internet round trip | | Retries | Managed retries (2x by default) | You implement | | Cost | One function invocation | One function invocation + one outbound HTTP request | ## Common patterns > [accordion] Escalate to human on certain keywords > > ```ts theme={null} > export default defineFunction(async (event) => { > if (event.type !== "message.inbound") return > const text = (event.data.text ?? "").toLowerCase() > if (/agente|human|hablar con alguien|complaint/.test(text)) { > await slack.send( > `Customer ${event.data.from} wants a human:\n> ${event.data.text}` > ) > } > }) > ``` > > Add a trigger on `message.inbound` and the agent on the same sender. Both > run; the LLM responds, your code pages the team. > [accordion] Track broadcast completion > > ```ts theme={null} > export default defineFunction(async (event) => { > if (event.type !== "broadcast.status_changed") return > if (event.data.status !== "completed") return > await analytics.track("broadcast_done", { > broadcastId: event.data.id, > deliveredCount: event.data.deliveredCount, > }) > }) > ``` > > ```sh theme={null} > npx zavudev fn triggers add --events broadcast.status_changed --senders any > ``` > [accordion] Welcome new contacts > > ```ts theme={null} > export default defineFunction(async (event, ctx) => { > if (event.type !== "conversation.new") return > const zavu = new Zavudev({ apiKey: process.env.ZAVU_API_KEY! }) > await zavu.messages.send({ > to: event.data.contactPhone, > channel: event.data.channel, > text: "¡Bienvenido! Soy Bella, en qué te puedo ayudar?", > }) > }) > ``` ## API equivalence ```sh theme={null} # List curl https://api.zavu.dev/v1/functions/$FN_ID/triggers \ -H "Authorization: Bearer $KEY" # Create curl -X POST https://api.zavu.dev/v1/functions/$FN_ID/triggers \ -H "Authorization: Bearer $KEY" \ -d '{"eventTypes":["message.inbound"],"senderIds":["snd_abc",null]}' # null in senderIds means "any sender" # Toggle curl -X PATCH https://api.zavu.dev/v1/functions/triggers/$TRIGGER_ID \ -H "Authorization: Bearer $KEY" \ -d '{"active":false}' # Delete curl -X DELETE https://api.zavu.dev/v1/functions/triggers/$TRIGGER_ID \ -H "Authorization: Bearer $KEY" ``` # eve Source: https://docs.zavu.dev/guides/integrations/eve Use Zavu from an agent built with eve — as an API connection today, or deploy the whole project onto Zavu. ## Zavu + eve [eve](https://eve.dev) is Vercel's open-source agent framework. There are two ways to combine it with Zavu, depending on where you want the agent to run. ## Option 1: deploy the eve project on Zavu If your agent is instructions + tools, deploy it directly — it becomes a first-class Zavu agent answering on your senders: ```sh theme={null} npx zavudev import owner/repo --sender sndr_123 ``` See [Deploy an eve project](/guides/functions/deploy-eve-project) for what maps, what doesn't, and the compatibility report. ## Option 2: keep the agent on eve, call Zavu as a connection If your agent relies on eve's own runtime (sandbox, subagents, durable sessions), keep it there and give it Zavu's messaging API as tools. eve's OpenAPI connections turn our spec into one tool per operation: ```ts theme={null} // agent/connections/zavu.ts import { defineOpenAPIConnection } from "eve/connections" export default defineOpenAPIConnection({ spec: "https://docs.zavu.dev/openapi.json", description: "Send SMS, WhatsApp, email, and voice messages via Zavu.", auth: { getToken: async () => ({ token: process.env.ZAVUDEV_API_KEY! }) }, operations: { // Start narrow; every operation is available if you want it. allow: ["sendMessage", "getMessage", "introspectPhone", "introspectEmail"], }, }) ``` The agent then gets tools like `zavu__sendMessage`. Recommendations: * Gate sends behind an approval so the model cannot message people unsupervised: add `approval: once()` (from `eve/tools/approval`) to the connection config if your review policy calls for it. * Scope the API key: create a key for this agent, and use a [sub-account](/guides/sub-accounts/overview) with a spending cap if the agent messages on behalf of a client. * `sendMessage` accepts phone numbers (E.164), email addresses, and chat IDs in the same `to` field, with `channel: "auto"` routing — one tool covers every channel. ## Which option to pick | Your agent needs | Run it on | | - | - | | Messaging channels, voice calls, an inbox, per-message billing | Zavu (option 1) | | Sandboxed compute, subagents, durable multi-day sessions | eve, calling Zavu (option 2) | Both directions use the same account, senders, and message history — you can prototype with option 2 and migrate to option 1 (or the reverse) without changing anything else. # n8n Source: https://docs.zavu.dev/guides/integrations/n8n Send and receive messages on every Zavu channel from an n8n workflow The Zavu community node brings the whole messaging API into [n8n](https://n8n.io): send on SMS, WhatsApp, Telegram, Email, Instagram, Messenger and Voice, run broadcasts, manage contacts and templates, and start workflows from inbound messages. The package ships two nodes: * **Zavu** sends messages and manages every resource in the API. * **Zavu Trigger** starts a workflow when something happens: a message arrives, a delivery status changes, a conversation opens, a call ends. > [!NOTE] > > The Zavu node is also available as a tool, so an n8n **AI Agent** can send a > WhatsApp message or look up a conversation on its own without you wiring it into > the flow by hand. ## Install **1. Open community nodes** In your self-hosted n8n, go to **Settings → Community Nodes → Install**. **2. Install the package** Enter `n8n-nodes-zavu`, accept the risks prompt, and confirm. **3. Restart if needed** n8n loads the nodes immediately. If they do not appear in the node panel, restart the instance. Prefer the command line? From your n8n user folder (`~/.n8n`): ```bash theme={null} npm install n8n-nodes-zavu ``` ### n8n Cloud n8n Cloud installs community nodes straight from the **Nodes panel**, but only those n8n has verified. This package is not verified yet, so today it needs self-hosted n8n. Until then, n8n Cloud can reach the same API through the **HTTP Request** node, which every operation here is a wrapper around: see the [API reference](/api-reference). > [!NOTE] > > `n8n-nodes-zavu` is rolling out to npm. If the install cannot find it yet, email > [support@zavu.dev](mailto:support@zavu.dev) and we will send you the package. ## Credentials Create a **Zavu API** credential and paste a project API key from **Dashboard → Settings → API Keys**. | Field | Notes | | - | - | | API Key | `zv_live_…` for production, `zv_test_…` for test mode | | Base URL | `https://api.zavu.dev`. Leave the default. | Click **Test** to confirm the key resolves to a project. A `zv_test_` key simulates sends against the WhatsApp sandbox instead of reaching the recipient, and cannot place voice calls. Use a live key for anything that must be delivered. ## What the Zavu node does | Resource | Operations | | - | - | | Message | Send, Get, Get Many, Get Attachments, React, Show Typing Indicator | | Conversation | Get, Get Many, Get Messages, Mark as Read | | Contact | Create, Get, Get by Phone, Get Many, Update, Delete, Add Channel, Merge | | Template | Create, Get, Get Many, Delete, Submit for Approval | | Broadcast | Create, Add Contacts, Send, Get, Get Many, Get Progress, Cancel, Delete | | Sender | Create, Get, Get Many, Update, Delete | | Agent | Create, Get, Get Many, Update, Delete, Test, Connect Sender, Disconnect Sender | | Voice Call | Create, Get, Get Many, Hang Up | | Phone Number | Search Available, Purchase, Get, Get Many, Update, Release | | Utility | Validate Phone, Validate Email, Submit URL, Get Balance, Get Account | | Custom API Call | Any endpoint the node does not model, with the same credential | Senders, templates, agents and phone numbers load as dropdowns, so you pick a name instead of pasting an ID. The sender dropdown also shows the channels each sender can actually send on, so a sender that is not ready yet is visible before you build the workflow around it. Every list operation walks the API cursor, so **Return All** really does return all of them rather than stopping at the first page. ### Sending **Channel** defaults to `Auto`, which routes on the sender's capabilities and the recipient. Any non-text message type is delivered over WhatsApp regardless. **To** takes a phone number in E.164, an email address, a numeric chat ID for Telegram, Instagram or Messenger, or a WhatsApp [business-scoped user ID](/guides/whatsapp/usernames). The node builds every message type for you: text, image, video, audio, document, sticker, location, contact card, [buttons](/guides/whatsapp/messages/buttons), [list](/guides/whatsapp/messages/list), [CTA URL](/guides/whatsapp/messages/cta-url), [location request](/guides/whatsapp/messages/location-request), [contact info request](/guides/whatsapp/messages/request-contact-info), and [template](/guides/whatsapp/templates/sending). ### Broadcasts from a spreadsheet **Broadcast → Add Contacts** can read one recipient per incoming item, which is the shape a Google Sheets or Postgres node already produces. Recipients are sent in batches of 1000, so a 5000 row sheet goes through in a single node. Sending a broadcast needs no account verification. What it passes through is content review. WhatsApp is the exception, because Meta's approval of the template is the review; what is enforced there is that the template is approved. Every other channel waits for the review, and a broadcast it refuses lands on `rejected`. Creating and editing drafts requires nothing. See [Broadcasts](/guides/broadcasts/overview) and [Sending limits](/concepts/sending-limits). ## Zavu Trigger Pick a sender and the events to react to. On activation the node points that sender's webhook at n8n, and on deactivation it restores whatever was configured before. Three things about Zavu webhooks shape how this node behaves. **Deliveries only go to HTTPS.** A local n8n hands out `http://localhost:5678/…`, which Zavu refuses. Expose n8n over HTTPS (a tunnel, or `WEBHOOK_URL` set to your public address) before activating the workflow. **A sender has exactly one webhook URL.** If it already points at your own backend, activation stops and tells you which URL is configured rather than silently taking it over. Turn on **Replace Existing Webhook** to repoint it; the previous URL is restored when you deactivate the trigger. **The webhook secret is revealed once.** Zavu generates and returns a secret when it configures a webhook on a sender that had none. For a sender that already had one, the existing secret is kept and never shown again, so paste it into **Webhook Secret** to let the node verify signatures. When the node has no secret it says so on the first delivery instead of quietly accepting unsigned traffic. Signature verification accepts both schemes Zavu emits and prefers the stronger one when a delivery carries both. See [Webhook security](/guides/receiving-messages/security). If you would rather configure the webhook yourself in the dashboard, turn off **Register Webhook Automatically** and the node only listens. ### Payload The trigger emits the webhook payload as sent: ```json theme={null} { "id": "evt_1736850000000_abc123", "type": "message.inbound", "timestamp": 1736850000000, "senderId": "jx7abc123def456", "projectId": "jx7xyz789ghi012", "data": { "messageId": "jd7x2k3m4n5p6q7r8s9t0", "from": "+56912345678", "to": "+13125551212", "channel": "whatsapp", "text": "Is my order shipped?" } } ``` Every event type is listed in [Webhook events](/guides/receiving-messages/events). ## Example: auto-reply to inbound WhatsApp **1. Add the trigger** **Zavu Trigger**, your sender, event **Message: Inbound**. **2. Show that you are working on it** **Zavu** → Message → Show Typing Indicator, Message ID `{{ $json.data.messageId }}`. Skip this if the reply is instant. **3. Reply** **Zavu** → Message → Send, To `{{ $json.data.from }}`, Channel WhatsApp. Under **Options**, set **Sender** to `{{ $json.senderId }}` so the answer leaves from the number the contact already knows. ## Troubleshooting > [accordion] Activation fails: Zavu only delivers webhooks to https\:// URLs > > Your n8n is handing out a local or plain HTTP address. Expose it over HTTPS > and set `WEBHOOK_URL` to that address, then activate again. To register the > webhook yourself instead, turn off **Register Webhook Automatically**. > [accordion] Activation fails: the sender already sends its webhooks somewhere else > > A sender has one webhook URL. Use a different sender, fan the events out from > your existing receiver, or turn on **Replace Existing Webhook** to repoint it > here. > [accordion] Deliveries are rejected for a signature mismatch > > The node is verifying with the wrong secret. If the sender already had a > webhook when you activated the trigger, Zavu never revealed its secret: paste > it into **Webhook Secret**. Regenerating the secret in the dashboard > invalidates the old one immediately, which breaks any other receiver using it. > [accordion] The workflow never fires > > Confirm the sender's webhook is active and subscribed to the events you > selected, under **Senders → your sender → Webhook** in the dashboard. Check > the trigger's event list matches the events you expect: deliveries of other > types are acknowledged and dropped. > [accordion] A send fails with whatsapp\_window\_closed > > Free-form WhatsApp messages need an open 24 hour window. Use a > [template](/guides/whatsapp/templates/sending) to start the conversation. ## Next steps > [card] [Webhook events](/guides/receiving-messages/events) > > Every event the trigger can subscribe to, and what each payload carries. > [card] [Smart routing](/guides/sending-messages/smart-routing) > > How `Auto` picks a channel per recipient. > [card] [Broadcasts](/guides/broadcasts/overview) > > Bulk sends, review and delivery tracking. > [card] [API reference](/api-reference) > > Everything the Custom API Call operation can reach. # Integrations Source: https://docs.zavu.dev/guides/integrations/overview Use Zavu from the tools you already run, with no code to write Zavu is one API for SMS, WhatsApp, Telegram, Email, Instagram, Messenger and Voice. These integrations put that API inside the tools you already use, so you can send and receive messages without wiring HTTP calls yourself. ## No-code and low-code > [card] [n8n](/guides/integrations/n8n) > > Send on every channel, run broadcasts, and start workflows from inbound > messages. Also available as a tool for n8n AI Agents. ## Platform and framework > [card] [Vercel](/guides/integrations/vercel) > > Automatic API key provisioning and environment variable injection for your > Vercel projects. > [card] [Supabase](https://supabase.com/partners/catalog/zavu) > > Listed in Supabase's partner catalog. Reach the Zavu API from your project. > [card] [eve](/guides/integrations/eve) > > Use Zavu from an agent built with Vercel's eve framework, or deploy the > whole project onto Zavu. ## For AI assistants and coding agents > [card] [MCP Server](/tools/mcp-server) > > Let Claude, Cursor and other MCP clients send messages and manage templates > in natural language. > [card] [Coding Agent Skills](/tools/coding-agent-skills) > > Give your coding agent real knowledge of the API so it writes correct Zavu > code the first time. ## Coming soon We are actively working on these. Tell us which one you need and we will prioritize it. > [card] Zapier > > Connect Zavu to 5,000+ apps with no-code automation workflows. > [card] Make > > Visual scenarios that send and react to messages. > [card] Stripe > > Payment confirmations, failed payment alerts and subscription reminders. > [card] Shopify > > Order confirmations, shipping updates and abandoned cart recovery. > [card] HubSpot > > CRM-triggered messaging for sales and marketing automation. > [card] [Request an integration](mailto:support@zavu.dev?subject=Integration%20Request) > > Email [support@zavu.dev](mailto:support@zavu.dev) with the platform and what you would trigger from it. ## Build your own Nothing here fits? Every integration above is built on the same public API, and so is anything you build. > [card] [Send a message](/guides/sending-messages/easy-way) > > One call, any channel. Start here. > [card] [Receive messages](/guides/receiving-messages/overview) > > Webhooks for inbound messages, delivery status and call events. > [card] [SDKs](/sdks/overview) > > TypeScript, Python, Ruby, Go and PHP. > [card] [Zavu Functions](/guides/functions/overview) > > Run your own TypeScript in Zavu Cloud, triggered by messaging events. # Vercel Integration Source: https://docs.zavu.dev/guides/integrations/vercel Connect Zavu to your Vercel projects for seamless messaging The Zavu Vercel integration automatically provisions API keys and injects environment variables into your Vercel projects, enabling you to start sending messages in minutes. ## Features * **One-Click Setup**: Install from the Vercel Marketplace and connect in minutes * **Automatic API Key Provisioning**: API keys are created and injected automatically * **Environment Variable Injection**: `ZAVUDEV_API_KEY` (also as `ZAVU_API_KEY`) and `ZAVU_PROJECT_ID` are added to your Vercel projects * **Multi-Project Support**: Connect multiple Vercel projects to a single Zavu project ## Installation **1. Install from Vercel Marketplace** Visit the [Zavu Integration on Vercel Marketplace](https://vercel.com/integrations/zavu) and click **Add Integration**. **2. Authorize Access** Grant Zavu permission to access your Vercel account and projects. **3. Select Zavu Project** Choose an existing Zavu project or create a new one to connect with your Vercel projects. **4. Choose Vercel Projects** Select which Vercel projects should receive the Zavu environment variables. ## Environment Variables After installation, the following environment variables are automatically added to your selected Vercel projects: | Variable | Description | | - | - | | `ZAVUDEV_API_KEY` | Your Zavu API key for authentication. The SDKs read it by default. | | `ZAVU_API_KEY` | The same key, under the name integrations set up before October 2026 used. Keep reading it if your code already does. | | `ZAVU_PROJECT_ID` | Your Zavu project ID | > [!NOTE] > > Environment variables are injected for **all environments** (Development, Preview, and Production). ## Quick Start Once the integration is complete, you can start sending messages immediately. The API key is already available as an environment variable. ### Install the SDK ```bash TypeScript theme={null} npm install @zavudev/sdk # or: bun add @zavudev/sdk ``` ```bash Python theme={null} pip install zavudev # or: uv add zavudev ``` ```bash Ruby theme={null} gem install zavudev ``` ```bash Go theme={null} go get github.com/zavudev/sdk-go ``` ```bash PHP theme={null} composer require zavudev/sdk ``` ### Send Your First Message ```typescript TypeScript theme={null} import Zavudev from "@zavudev/sdk"; const zavu = new Zavu({ apiKey: process.env.ZAVUDEV_API_KEY!, }); // Send a message const { message } = await zavu.messages.send({ to: "+1234567890", text: "Hello from Zavu!", }); console.log("Message ID:", message.id); ``` ```python Python theme={null} import os from zavudev import Zavu client = Zavu( api_key=os.environ["ZAVUDEV_API_KEY"], ) # Send a message message = client.messages.send( to="+1234567890", text="Hello from Zavu!", ) print(f"Message ID: {message.message.id}") ``` ```ruby Ruby theme={null} require "zavudev" client = Zavudev::Client.new(api_key: ENV["ZAVUDEV_API_KEY"]) # Send a message message = client.messages.send_( to: "+1234567890", text: "Hello from Zavu!" ) puts "Message ID: #{message.message.id}" ``` ```go Go theme={null} package main import ( "context" "fmt" "os" "github.com/zavudev/sdk-go" ) func main() { client := zavudev.NewClient(zavudev.WithAPIKey(os.Getenv("ZAVUDEV_API_KEY"))) message, _ := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("+1234567890"), Text: zavudev.String("Hello from Zavu!"), }) fmt.Printf("Message ID: %s\n", message.Message.ID) } ``` ```php PHP theme={null} $client = new Zavudev\Client(apiKey: getenv('ZAVUDEV_API_KEY')); // Send a message $message = $client->messages->send([ 'to' => '+1234567890', 'text' => 'Hello from Zavu!', ]); echo "Message ID: {$message->message->id}\n"; ``` ```typescript Next.js API Route theme={null} // app/api/send-message/route.ts import Zavudev from "@zavudev/sdk"; import { NextResponse } from "next/server"; const zavu = new Zavu({ apiKey: process.env.ZAVUDEV_API_KEY!, }); export async function POST(request: Request) { const { to, text } = await request.json(); const { message } = await zavu.messages.send({ to, text }); return NextResponse.json({ messageId: message.message.id }); } ``` ```typescript Next.js Server Action theme={null} // app/actions/messaging.ts "use server"; import Zavudev from "@zavudev/sdk"; const zavu = new Zavu({ apiKey: process.env.ZAVUDEV_API_KEY!, }); export async function sendMessage(to: string, text: string) { const { message } = await zavu.messages.send({ to, text }); return message.message.id; } ``` ## Managing the Integration ### View Connected Projects Go to **Integrations** in your [Zavu Dashboard](https://dashboard.zavu.dev/integrations) to see all connected Vercel projects and their status. ### Disconnect Disconnect from **Developers › Integrations** in the Zavu dashboard. Zavu revokes the API key and removes the variables it added (`ZAVUDEV_API_KEY`, `ZAVU_API_KEY`, `ZAVU_PROJECT_ID`) from each connected Vercel project. If a project could not be cleaned, the integration's row says which one: remove the variables there by hand. > [!WARNING] > > Removing the integration from the Vercel side instead (**Settings > Integrations** in Vercel) revokes Zavu's access to your Vercel account first, so Zavu cannot remove the variables: delete them in the project's settings. ### Rotate API Key If you need to rotate your API key: 1. Go to [API Keys](https://dashboard.zavu.dev/api-keys) in your Zavu Dashboard 2. Create a new API key 3. Update the `ZAVUDEV_API_KEY` environment variable in your Vercel project settings 4. Revoke the old API key ## Troubleshooting ### Environment Variables Not Appearing If the environment variables are not showing up in your Vercel project: 1. Check the integration status in your Zavu Dashboard 2. Re-deploy your Vercel project to pick up the new environment variables 3. Ensure the integration has the correct permissions ### OAuth Errors If you encounter OAuth errors during setup: 1. Try disconnecting and reconnecting the integration 2. Clear your browser cookies for Vercel and Zavu 3. Ensure popup blockers are disabled ### API Key Not Working If messages fail to send: 1. Verify the API key is correctly set in your environment 2. Read the error code on the response or on the failed message: a daily ceiling (`daily_limit_exceeded`), a missing 10DLC campaign on a +1 SMS destination (`ten_dlc_required`), or a message the automated review stopped (`RISK_BLOCKED`, `RISK_REJECTED`, `RISK_REVIEW_EXPIRED`). See [Sending limits](/concepts/sending-limits) 3. Review the [API Logs](https://dashboard.zavu.dev/api-logs) for detailed error messages ## Use Cases > [card] Transactional Notifications > > Send order confirmations, shipping updates, and password resets > [card] Two-Factor Authentication > > Implement SMS-based 2FA for your Next.js applications > [card] Customer Support > > Enable WhatsApp messaging for real-time customer communication > [card] Marketing Campaigns > > Run targeted SMS and WhatsApp marketing campaigns ## Next Steps > [card] [Sending Messages](/guides/sending-messages/easy-way) > > Learn different ways to send messages > [card] [WhatsApp](/guides/whatsapp/overview) > > Send rich WhatsApp messages with media and buttons > [card] [Webhooks](/guides/receiving-messages/webhooks) > > Receive delivery status updates and incoming messages > [card] [AI Agents](/guides/ai-agents/overview) > > Build automated conversational agents # Messenger Overview Source: https://docs.zavu.dev/guides/messenger/overview Facebook Messenger and Marketplace chat messaging through your Facebook Page The Messenger channel lets you receive and reply to Facebook Messenger conversations — including **Facebook Marketplace chats** — through your Facebook Page, using the same unified API as every other Zavu channel. ## When to Use Messenger * **Marketplace sellers**: Reply to buyer inquiries on your listings, automatically or from the Inbox * **Customer support**: Handle Page messages alongside WhatsApp, SMS, and Email in one place * **AI agents**: Let a Zavu AI Agent answer Messenger conversations 24/7 ## Basic Messenger Message ```typescript TypeScript theme={null} const result = await zavu.messages.send({ to: "24025631120151183", // Messenger user ID (PSID) text: "Thanks for your message!", channel: "messenger", }); ``` ```python Python theme={null} result = zavu.messages.send( to="24025631120151183", # Messenger user ID (PSID) text="Thanks for your message!", channel="messenger" ) ``` ```ruby Ruby theme={null} result = client.messages.send_( to: "24025631120151183", # Messenger user ID (PSID) text: "Thanks for your message!", channel: "messenger" ) ``` ```go Go theme={null} result, _ := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("24025631120151183"), // Messenger user ID (PSID) Text: zavudev.String("Thanks for your message!"), Channel: zavudev.String("messenger"), }) ``` ```php PHP theme={null} $result = $client->messages->send([ 'to' => '24025631120151183', // Messenger user ID (PSID) 'text' => 'Thanks for your message!', 'channel' => 'messenger', ]); ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "24025631120151183", "text": "Thanks for your message!", "channel": "messenger" }' ``` ## Facebook Marketplace Chats When a buyer messages your Page from a Marketplace listing, the conversation arrives through the same Messenger channel: 1. A buyer taps **Message** on your Marketplace listing 2. Zavu receives the message via your connected Facebook Page and creates a contact + conversation 3. You receive a `message.inbound` webhook (or your AI Agent replies automatically) 4. Reply from the Inbox, via the API, or let your agent handle it > [!NOTE] > > Marketplace conversations follow the same rules as regular Page messages: same user IDs, same 24-hour window, same API. ## User IDs vs Phone Numbers Like Telegram and Instagram, Messenger uses numeric user IDs (PSIDs — Page-Scoped IDs) instead of phone numbers: ``` Phone-based channels: +14155551234 Messenger: 24025631120151183 ``` > [!WARNING] > > You cannot message a Messenger user by phone number. The user must message your Page first — their PSID arrives in the `from` field of the inbound message (prefixed as `messenger:{id}` in contacts and conversations). PSIDs are scoped per Page: the same person has a different ID for each Facebook Page they talk to. ## 24-Hour Messaging Window Messenger enforces a standard messaging window: * You can reply freely within **24 hours** of the user's last message * Outside the window, Meta rejects the send and the message fails with a clear error * There are no pre-approved templates like WhatsApp — the user must message again to reopen the window ## Message Types | Type | Use Case | | - | - | | Text | Simple replies | | Image | Photos and graphics | | Video | Video clips | | Audio | Voice notes | | Document | Files and attachments | ## Delivery Status | Status | Description | | - | - | | `queued` | Message accepted by Zavu | | `sending` | Being sent to Meta | | `sent` | Accepted by Messenger | | `failed` | Delivery failed (e.g., window closed) | ## Setup Requirements Before sending Messenger messages, you need to: 1. A Facebook Page (the Page your Marketplace listings or business uses) 2. Connect the Page in your Zavu sender settings via Meta login See [Messenger Setup](/guides/messenger/setup) for detailed instructions. ## Common Errors | Error | Cause | Solution | | - | - | - | | `24-hour messaging window closed` | User hasn't messaged in 24h | Wait for the user to message again | | `Messenger requires a Facebook Page to be connected` | Channel not configured | Connect a Page in sender settings | | `Invalid user ID` | Wrong PSID | Use the PSID from the inbound message | ## Next Steps * [Messenger Setup](/guides/messenger/setup) - Connect your Facebook Page * [Receiving Messages](/guides/receiving-messages/webhooks) - Handle inbound webhooks * [AI Agents](/guides/ai-agents/overview) - Auto-reply to Messenger chats # Messenger Setup Source: https://docs.zavu.dev/guides/messenger/setup Connect your Facebook Page to receive Messenger and Marketplace chats This guide walks you through connecting a Facebook Page to Zavu so you can receive and reply to Messenger conversations, including Facebook Marketplace chats. ## Prerequisites * A **Facebook Page** (personal profiles cannot be connected) * Admin access to that Page ## Step 1: Open Messenger Setup 1. Go to **Senders** in your Zavu dashboard 2. Select your sender or create a new one 3. In the **Channels** tab, click **Add** next to Messenger ## Step 2: Connect with Meta 1. Click **Connect with Meta** 2. Log in with the Facebook account that manages your Page 3. Grant the requested permissions (Page messaging and Page access) 4. Select the Facebook Page you want to connect 5. Click **Save** > [!NOTE] > > The login popup must be allowed by your browser. If nothing happens, check your popup blocker. ## Step 3: Webhooks (Automatic) When you save the connection, Zavu automatically subscribes your Page to messaging webhooks. Inbound Messenger and Marketplace chats start flowing into your Inbox immediately — no manual webhook configuration needed. ## Testing the Connection 1. Open your Facebook Page (or a Marketplace listing on that Page) 2. From another Facebook account, send a message to the Page 3. Check your Zavu **Inbox** — the conversation appears with the sender's user ID 4. Reply from the Inbox or via the API: ```typescript TypeScript theme={null} const result = await zavu.messages.send({ to: "24025631120151183", // User ID from the inbound message text: "Thanks for reaching out!", channel: "messenger", }); ``` ```python Python theme={null} result = zavu.messages.send( to="24025631120151183", # User ID from the inbound message text="Thanks for reaching out!", channel="messenger" ) ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "24025631120151183", "text": "Thanks for reaching out!", "channel": "messenger" }' ``` ## Marketplace Chats No extra configuration is needed for Marketplace: buyer messages sent from your listings arrive through the same Page messaging webhook as regular Messenger chats. > [!WARNING] > > Meta may restrict programmatic access to Marketplace conversations for some Page types or regions. If Marketplace messages don't arrive but regular Page messages do, verify your Page's Marketplace access with Meta. ## Auto-Reply with AI Agents To answer Messenger chats automatically, configure an [AI Agent](/guides/ai-agents/overview) on the same sender. The agent triggers on inbound Messenger messages and replies within the 24-hour window. ## Troubleshooting | Problem | Solution | | - | - | | No pages listed after login | Make sure your account is an admin of the Page and you granted Page access during login | | Inbound messages not arriving | Reconnect the Page from the channel settings to refresh the webhook subscription | | Sends fail with window closed error | The user's last message is older than 24 hours — wait for them to message again | | Login popup blocked | Allow popups for the Zavu dashboard and retry | # Onboarding Clients via API Source: https://docs.zavu.dev/guides/partner-invitations/api Programmatically create partner invitations to let clients connect their WhatsApp Business account or Facebook Page This guide shows you how to automate client onboarding using the Zavu API to create partner invitations programmatically. ## Overview The Partner Invitations API allows you to: * Create invitation links programmatically * Onboard a client on WhatsApp (`whatsapp_waba`) or Messenger (`messenger`) * Integrate client onboarding into your own application * Optionally pre-assign phone numbers to WhatsApp invitations * Track invitation status and completion ### Choosing a channel `connectionType` decides what the client connects. It defaults to `whatsapp_waba`, so existing integrations keep working unchanged. | `connectionType` | Client authorizes | Result | | - | - | - | | `whatsapp_waba` | Meta's embedded signup | A sender with the WhatsApp channel | | `messenger` | Facebook, then picks a Page they administer | A sender with the Messenger channel, receiving Page and Marketplace chats | One invitation connects one channel. To onboard a client on both, create two invitations; each completes into its own sender. ## Creating an Invitation ### Basic Invitation Create an invitation where the client provides their own phone number: ```typescript TypeScript theme={null} import Zavudev from '@zavudev/sdk'; const zavu = new Zavudev({ apiKey: process.env["ZAVUDEV_API_KEY"], }); const invitation = await zavu.invitations.create({ clientName: 'Acme Corp', clientEmail: 'contact@acme.com', expiresInDays: 7, }); console.log('Invitation URL:', invitation.url); console.log('Token:', invitation.token); console.log('Expires:', invitation.expiresAt); ``` ```ruby Ruby theme={null} require "zavudev" client = Zavudev::Client.new(api_key: ENV["ZAVUDEV_API_KEY"]) invitation = client.invitations.create( client_name: "Acme Corp", client_email: "contact@acme.com", expires_in_days: 7 ) puts "Invitation URL: #{invitation.url}" puts "Token: #{invitation.token}" puts "Expires: #{invitation.expires_at}" ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/invitations \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "clientName": "Acme Corp", "clientEmail": "contact@acme.com", "expiresInDays": 7 }' ``` ### Response ```json theme={null} { "invitation": { "id": "jh7am5bng9p3v2x1k4r8", "url": "https://dashboard.zavu.dev/invite/abc123xyz", "token": "abc123xyz", "clientName": "Acme Corp", "clientEmail": "contact@acme.com", "phoneNumberId": null, "status": "pending", "senderId": null, "expiresAt": "2025-01-22T00:00:00.000Z", "createdAt": "2025-01-15T12:00:00.000Z" } } ``` ### With Pre-assigned Phone Number Pre-assign a Zavu phone number that the client will register with WhatsApp: ```typescript TypeScript theme={null} // First, purchase a phone number const phoneNumber = await zavu.phoneNumbers.purchase({ phoneNumber: '+14155551234', name: 'Client: Acme Corp', }); // Then create the invitation with the phone number const invitation = await zavu.invitations.create({ clientName: 'Acme Corp', clientEmail: 'contact@acme.com', phoneNumberId: phoneNumber.id, expiresInDays: 14, }); console.log('Invitation URL:', invitation.url); ``` ```ruby Ruby theme={null} # First, purchase a phone number phone_number = client.phone_numbers.purchase( phone_number: "+14155551234", name: "Client: Acme Corp" ) # Then create the invitation with the phone number invitation = client.invitations.create( client_name: "Acme Corp", client_email: "contact@acme.com", phone_number_id: phone_number.id, expires_in_days: 14 ) puts "Invitation URL: #{invitation.url}" ``` ```bash cURL theme={null} # First, purchase a phone number curl -X POST https://api.zavu.dev/v1/phone-numbers \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "phoneNumber": "+14155551234", "name": "Client: Acme Corp" }' # Then create the invitation with the phone number ID curl -X POST https://api.zavu.dev/v1/invitations \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "clientName": "Acme Corp", "clientEmail": "contact@acme.com", "phoneNumberId": "pn_abc123", "expiresInDays": 14 }' ``` > [!NOTE] > > When you pre-assign a phone number, the invitation page will show the number and automatically display the verification code when it's received via phone call. ### Messenger Invitation Create an invitation for a Facebook Page. There is no phone number involved, so the WhatsApp-only options are rejected with `400`. ```typescript TypeScript theme={null} const { invitation } = await zavu.invitations.create({ clientName: 'Acme Corp', clientEmail: 'contact@acme.com', connectionType: 'messenger', }); console.log(invitation.url); ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/invitations \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "clientName": "Acme Corp", "clientEmail": "contact@acme.com", "connectionType": "messenger" }' ``` > [!NOTE] > > A Facebook Page can only be connected to one Zavu project at a time. If the client picks a Page that another project already connected, the newer connection wins and the older one is disconnected. ### Create Parameters | Parameter | Type | Required | Description | | - | - | - | - | | `clientName` | string | No | Name to identify the client (max 100 chars) | | `clientEmail` | string | No | Client's email address | | `clientPhone` | string | No | Client's phone number (E.164 format) | | `connectionType` | string | No | `whatsapp_waba` (default) or `messenger` | | `phoneNumberId` | string | No | Pre-assign a Zavu phone number. `whatsapp_waba` only | | `allowedPhoneCountries` | string\[] | No | ISO country codes for allowed numbers. `whatsapp_waba` only | | `expiresInDays` | integer | No | Link validity in days (1-30, default: 7) | ## Listing Invitations Retrieve all invitations for your project: ```typescript TypeScript theme={null} // List all invitations const { items } = await zavu.invitations.list({}); // Filter by status const pending = await zavu.invitations.list({ status: 'pending', }); for (const inv of pending.items) { console.log(inv.clientName, inv.status, inv.url); } ``` ```ruby Ruby theme={null} # List all invitations result = client.invitations.list # Filter by status pending = client.invitations.list(status: "pending") pending.items.each do |inv| puts "#{inv.client_name} #{inv.status} #{inv.url}" end ``` ```bash cURL theme={null} # List all invitations curl "https://api.zavu.dev/v1/invitations" \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" # Filter by status curl "https://api.zavu.dev/v1/invitations?status=pending" \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` ### List Parameters | Parameter | Type | Description | | - | - | - | | `status` | string | Filter by status: `pending`, `in_progress`, `completed`, `expired`, `cancelled` | | `limit` | integer | Results per page (default: 50, max: 100) | | `cursor` | string | Pagination cursor | ## Getting Invitation Details Retrieve details for a specific invitation: ```typescript TypeScript theme={null} const invitation = await zavu.invitations.retrieve({ invitationId: 'jh7am5bng9p3v2x1k4r8', }); console.log('Status:', invitation.status); console.log('Client:', invitation.clientName); if (invitation.status === 'completed') { console.log('Sender ID:', invitation.senderId); } ``` ```ruby Ruby theme={null} invitation = client.invitations.retrieve( invitation_id: "jh7am5bng9p3v2x1k4r8" ) puts "Status: #{invitation.status}" puts "Client: #{invitation.client_name}" if invitation.status == "completed" puts "Sender ID: #{invitation.sender_id}" end ``` ```bash cURL theme={null} curl "https://api.zavu.dev/v1/invitations/jh7am5bng9p3v2x1k4r8" \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` ### Response Fields | Field | Type | Description | | - | - | - | | `id` | string | Unique invitation ID | | `url` | string | Full invitation URL to share with client | | `token` | string | URL token (part of the URL) | | `clientName` | string | Client name | | `clientEmail` | string | Client email | | `phoneNumberId` | string | Pre-assigned phone number ID (if any) | | `connectionType` | string | `whatsapp_waba` or `messenger` | | `status` | string | Current status | | `senderId` | string | Created sender ID (when completed) | | `connectedAccount` | object | What was linked, once completed: `{ channel, id, name }`. The WhatsApp number, or the Facebook Page. `null` before completion | | `failureReason` | string | Why the last attempt failed, when `status` is `failed` | | `failedAt` | string | When the last attempt failed | | `expiresAt` | string | Expiration timestamp | | `viewedAt` | string | When client first viewed the invitation | | `startedAt` | string | When client started the setup | | `completedAt` | string | When setup was completed | ## Cancelling an Invitation Cancel an invitation to prevent it from being used: ```typescript TypeScript theme={null} await zavu.invitations.cancel({ invitationId: 'jh7am5bng9p3v2x1k4r8', }); ``` ```ruby Ruby theme={null} client.invitations.cancel( invitation_id: "jh7am5bng9p3v2x1k4r8" ) ``` ```bash cURL theme={null} curl -X POST "https://api.zavu.dev/v1/invitations/jh7am5bng9p3v2x1k4r8/cancel" \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` > [!NOTE] > > You cannot cancel a completed invitation. Once a sender is created, manage it through the Senders API instead. ## Invitation Lifecycle ```mermaid theme={null} stateDiagram-v2 [*] --> pending: Create invitation pending --> in_progress: Client starts setup pending --> cancelled: Cancel invitation in_progress --> completed: Setup successful in_progress --> failed: Client cancelled, denied, or abandoned in_progress --> cancelled: Cancel invitation failed --> in_progress: Client retries the same link failed --> cancelled: Cancel invitation failed --> pending: Resend from the dashboard cancelled --> pending: Resend from the dashboard completed --> [*]: Sender created ``` A `failed` invitation is not dead: the link stays usable and moves back to `in_progress` when the client tries again. `failureReason` carries a stable code — `fb_cancelled`, `fb_not_authorized`, `signup_abandoned`, `meta_no_pages` (the client administers no Facebook Page), and others. Treat unknown codes as a generic failure. A client who starts the connection and does not finish within an hour is marked `failed` with `signup_abandoned`. **Expiry is not a transition.** A `pending` invitation past `expiresAt` is reported as `expired` when you read it, and its link stops working, but its stored status never changes. That means no `invitation.status_changed` event is sent when an invitation expires. Resend it from the dashboard, or create a new one. Every arrow above except expiry sends an `invitation.status_changed` event. See [Webhook Integration](#webhook-integration). ## Integration Examples ### SaaS Onboarding Flow Integrate WhatsApp setup into your SaaS onboarding: ```typescript theme={null} async function onboardNewCustomer(customer: Customer) { // 1. Create the customer in your system await createCustomer(customer); // 2. Create a Zavu invitation const invitation = await zavu.invitations.create({ clientName: customer.companyName, clientEmail: customer.email, expiresInDays: 14, }); // 3. Send onboarding email with the invitation link await sendEmail({ to: customer.email, subject: 'Connect Your WhatsApp Business', body: ` Welcome to our platform! To enable WhatsApp messaging, please complete the setup: ${invitation.url} This link expires in 14 days. `, }); // 4. Store the invitation ID for tracking await saveInvitationId(customer.id, invitation.id); } ``` ### Polling for Completion Check if a client has completed the setup: ```typescript theme={null} async function checkInvitationStatus(invitationId: string) { const invitation = await zavu.invitations.retrieve({ invitationId }); switch (invitation.status) { case 'completed': console.log('Client connected! Sender ID:', invitation.senderId); // Start sending messages through this sender break; case 'pending': case 'in_progress': console.log('Still waiting for client to complete setup'); break; case 'expired': console.log('Invitation expired, creating new one...'); // Create a new invitation break; case 'cancelled': console.log('Invitation was cancelled'); break; } return invitation; } ``` ### Webhook Integration Subscribe your project webhook to `invitation.status_changed` to follow each client without polling. Configure it on the project whose API key creates the invitations: with sub-accounts, that is each sub-account, since a parent project does not receive its sub-accounts' events. ```bash theme={null} curl -X POST https://api.zavu.dev/v1/invitations/webhook \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-app.com/webhooks/zavu", "events": ["invitation.status_changed"] }' ``` ```typescript theme={null} // In your webhook handler. Verify the X-Zavu-Signature header first. app.post('/webhooks/zavu', async (req, res) => { const event = req.body; if (event.type === 'invitation.status_changed') { const { invitationId, currentStatus, senderId, connectedAccount, failureReason } = event.data; if (currentStatus === 'completed') { // The client is connected; senderId is ready to send with await markCustomerConnected(invitationId, senderId, connectedAccount); } else if (currentStatus === 'failed') { // The same link still works, so a reminder is usually enough await remindCustomer(invitationId, failureReason); } } res.status(200).send('OK'); }); ``` See [the event reference](/guides/receiving-messages/events) for every field and transition. ## Error Handling ```typescript TypeScript theme={null} try { const invitation = await zavu.invitations.create({ clientName: 'Acme Corp', phoneNumberId: 'pn_invalid', }); } catch (error) { if (error.code === 'not_found') { console.error('Phone number not found'); } else if (error.code === 'invalid_request') { console.error('Invalid request:', error.message); } else { console.error('Unexpected error:', error); } } ``` ```ruby Ruby theme={null} begin invitation = client.invitations.create( client_name: "Acme Corp", phone_number_id: "pn_invalid" ) rescue Zavudev::Errors::Error => e case e.code when "not_found" puts "Phone number not found" when "invalid_request" puts "Invalid request: #{e.message}" else puts "Unexpected error: #{e}" end end ``` ### Common Errors | Code | Description | | - | - | | `not_found` | Phone number or invitation not found | | `invalid_request` | Invalid parameters | | `phone_number_wrong_team` | Phone number belongs to a different team | | `already_completed` | Cannot cancel a completed invitation | ## Best Practices > [card] Store Invitation IDs > > Save the invitation ID in your database to track status and link to your customer records. > [card] Set Reminders > > If an invitation is pending for several days, send a reminder to your client. > [card] Handle Expiration > > Expiry sends no event. Check `expiresAt` on pending invitations and resend or recreate them. > [card] Use Webhooks > > Subscribe to `invitation.status_changed` to know the moment a client connects or fails. ## Rate Limits The Invitations API has the following rate limits: | Endpoint | Limit | | - | - | | Create invitation | 100 requests/minute | | List invitations | 200 requests/minute | | Get invitation | 200 requests/minute | | Cancel invitation | 100 requests/minute | ## Next Steps > [card] [Dashboard Guide](/guides/partner-invitations/dashboard) > > Learn to create invitations from the dashboard > [card] [Senders API](/concepts/senders) > > Work with senders created from invitations > [card] [Send WhatsApp Messages](/guides/whatsapp/overview) > > Start sending messages through your new senders > [card] [Phone Numbers](/guides/phone-numbers/purchasing) > > Purchase phone numbers to pre-assign to invitations # Onboarding Clients via Dashboard Source: https://docs.zavu.dev/guides/partner-invitations/dashboard Send a client a link to connect their WhatsApp Business account or Facebook Page into your project A client invitation is a link. Your client opens it, signs in with Facebook, and connects their own WhatsApp Business account or Facebook Page. When they finish, the channel shows up in your project as a new sender. ## Create an invitation 1. In the [dashboard](https://dashboard.zavu.dev), select the project the client's channel should land in. 2. Open **Client invitations** and click **New invitation**. 3. Choose the **Channel** and type the **Client name**. Both are required here: the name is how you find the client in the list later. The dialog also shows the name your client will read on the invitation page, which is your workspace's name; change it from the workspace switcher. 4. Optionally, open the extra options (in the new interface they are under **Email, number and expiry**): | Field | Description | | - | - | | Client email | The link is also emailed to this address. | | WhatsApp number | WhatsApp only. Pre-assigns a number from this project. Only numbers that are active, approved and not on a sender are offered. | | Link expires in | 1 to 30 days. Defaults to 7. | 5. Click **Create link**. The dialog shows the link and a ready-to-send message for your client, with the link, what to have at hand and its expiry. Each pending row also has a **Copy link** button. > [!NOTE] > > One invitation connects one channel. To onboard a client on WhatsApp and Messenger, create two invitations; each completes into its own sender. ### What it costs Before you create the link, the dialog says what the connection will cost on your plan: * **Free**: each connection uses one of the two connections your workspace includes, and Free allows a single WhatsApp Business account among them. When the slots, or the WhatsApp account, are already in use, your client's connection still completes, but it stays inactive until you free one or upgrade. The dialog names what is holding them. * **Paid plans**: a connection your plan does not include is created inactive and costs nothing until you activate it, after seeing the price. An inactive connection does not send or receive. Activate it from **Connected channels** (**Accounts** in the current interface), or with `POST /v1/senders/{senderId}/channels/{channel}/activate`. ## What your client sees ### WhatsApp 1. The invitation page, with your company name and what they need: a Facebook account with access to their business, and a phone number for WhatsApp unless you pre-assigned one. 2. Facebook's window, where they sign in, select or create a WhatsApp Business Account, and choose the number. 3. A confirmation when the number is connected. If the number already runs on the **WhatsApp Business App**, your client can choose to connect that app in Facebook's window instead of registering the number from scratch. They keep using the app on their phone, and Zavu sends and receives through the same number. See [WhatsApp Business App connection](/guides/whatsapp/business-app) for the limits of this mode. A number used on personal WhatsApp cannot be connected until it is removed from it. ### Pre-assigned number If you pre-assigned a number, the invitation page shows it. Your client enters it in Facebook's window and chooses **Phone call** to verify it. Zavu answers the call and shows the code on the invitation page for them to type. ### Messenger Your client signs in with Facebook and picks a Page they administer. That Page's Messenger inbox, including Marketplace chats, is routed to Zavu. They need admin access to at least one Page. > [!WARNING] > > A Facebook Page can be connected to one Zavu project at a time. If your client picks a Page another project already connected, the newer connection wins and the older one stops receiving messages. ## Follow invitations ### Statuses | Status | Meaning | | - | - | | **Pending** | The link is waiting. In the new interface the row also says whether your client has opened it. | | **Connecting** | Your client is in Facebook's window right now. | | **Connected** | The channel is in your project. The row names the number or Page, links to the sender and shows its ID. | | **Failed** | Your client started and did not finish. The reason is written under the status, with whether the same link still works for a retry. | | **Expired** | The link expired before anyone used it. | | **Cancelled** | You cancelled it. | A connected row says **Inactive** when the connection still needs activating. In the new interface it also shows the last seven days: how many conversations there were, how many are waiting for a reply (with a link straight to it when there is one), and a link to connect an agent if the sender has none switched on. The new interface opens on **Active** invitations; cancelled ones, and links that expired unused or after a failure, are under **All**. ### Actions | Action | When | | - | - | | **Copy link** | While the link still works. | | **Generate a new link** | Pending, failed, expired or cancelled invitations. The old link stops working, and the new one lives as many days as the original. With an email on file, the new link is also emailed. | | **Cancel invitation** | Pending, failed or expired invitations. | Neither a new link nor a cancel is possible while the status is **Connecting**: your client is using the link at that moment. If they close the window without finishing, the invitation moves to **Failed** within two hours and both actions become available. ### Webhook Every status change except expiry is delivered to your project webhook as `invitation.status_changed`. Configure it under **Webhooks**, or with `POST /v1/invitations/webhook`. See [API integration](/guides/partner-invitations/api). ## Troubleshooting | Issue | What to do | | - | - | | Your client sees "Invalid invitation" | The link expired, was cancelled or was replaced by a new one. Generate a new link. | | Facebook did not return the account or the number | Usually a browser extension blocking Facebook's window. Ask your client to retry with the same link in another browser. | | Your client closed Facebook's window | The same link works for a retry until it expires. | | The verification code never arrives on a pre-assigned number | Make sure your client chose **Phone call**, not SMS. | | The invitation is connected but nothing sends | The connection is inactive. Activate it from **Connected channels**. | ## Next steps > [card] [API integration](/guides/partner-invitations/api) > > Create invitations from your own product > [card] [Send messages](/guides/whatsapp/overview) > > Send through your client's new sender # Purchasing Phone Numbers Source: https://docs.zavu.dev/guides/phone-numbers/purchasing Search for and purchase phone numbers to send messages Phone numbers are required to send SMS and WhatsApp messages. Zavu lets you search for available numbers and purchase them directly through the API or dashboard. **How to buy a phone number** ## Plans and the Included Number Buying phone numbers requires a paid plan: on the Free plan the purchase is refused with `402 paid_plan_required`. A paid plan includes one number at no charge, once per account: it must be a US or Canadian number (a +1 number) costing \$20 a month or less. The included number is one per account, across every team its owner owns, and is granted once: releasing it does not make another number free. Search results mark the numbers that qualify with `pricing.isFreeEligible`. ## Searching for Available Numbers Before purchasing, search for available numbers in your target country: ```typescript TypeScript theme={null} const result = await zavu.phoneNumbers.searchAvailable({ countryCode: "US", type: "local", limit: 10, }); for (const number of result.items) { console.log(number.phoneNumber, number.locality, number.region); console.log("Monthly price:", number.pricing.monthlyPrice); console.log("Free eligible:", number.pricing.isFreeEligible); } ``` ```python Python theme={null} result = zavu.phone_numbers.search_available( country_code="US", type="local", limit=10 ) for number in result.items: print(number.phone_number, number.locality, number.region) print("Monthly price:", number.pricing.monthly_price) print("Free eligible:", number.pricing.is_free_eligible) ``` ```ruby Ruby theme={null} result = client.phone_numbers.search_available( country_code: "US", type: "local", limit: 10 ) result.items.each do |number| puts "#{number.phone_number} #{number.locality} #{number.region}" puts "Monthly price: #{number.pricing.monthly_price}" puts "Free eligible: #{number.pricing.is_free_eligible}" end ``` ```go Go theme={null} result, _ := client.PhoneNumbers.SearchAvailable(context.TODO(), zavudev.PhoneNumberSearchAvailableParams{ CountryCode: "US", Type: zavudev.PhoneNumberTypeLocal, Limit: zavudev.Int(10), }) for _, number := range result.Items { fmt.Println(number.PhoneNumber, number.Locality, number.Region) fmt.Println("Monthly price:", number.Pricing.MonthlyPrice) fmt.Println("Free eligible:", number.Pricing.IsFreeEligible) } ``` ```php PHP theme={null} $result = $client->phoneNumbers->searchAvailable(countryCode: 'US', type: 'local', limit: 10); foreach ($result->items as $number) { echo "{$number->phoneNumber} {$number->locality} {$number->region}\n"; echo "Monthly price: {$number->pricing->monthlyPrice}\n"; echo "Free eligible: {$number->pricing->isFreeEligible}\n"; } ``` ```bash cURL theme={null} curl "https://api.zavu.dev/v1/phone-numbers/available?countryCode=US&type=local&limit=10" \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` ### Search Parameters | Parameter | Type | Required | Description | | - | - | - | - | | `countryCode` | string | Yes | Two-letter ISO country code (e.g., "US", "GB") | | `type` | string | No | Number type: `local`, `national`, `mobile`, or `tollFree` (default: `local`) | | `contains` | string | No | Search for numbers containing this pattern (e.g., "555") | | `limit` | integer | No | Max results to return (default: 10, max: 50) | ### Response ```json theme={null} { "items": [ { "phoneNumber": "+14155551234", "friendlyName": "(415) 555-1234", "locality": "San Francisco", "region": "CA", "capabilities": { "sms": true, "voice": true, "mms": true }, "pricing": { "monthlyPrice": 1.25, "upfrontPrice": 1.00, "isFreeEligible": true } } ] } ``` ### Empty results and errors | Response | Meaning | | - | - | | `200` with empty `items` | The country is served, but nothing matched these filters: no stock of this `type`, or no number containing these digits. A `type` the country does not sell answers this way too: retry with another `type`. | | `400 country_restricted` | The country is not sold self-service. The message names who to contact. | | `400 invalid_request` with `details.field: "contains"` | The inventory refused the `contains` pattern itself. A pattern that only matched nothing is answered `200` with empty `items`. | | `404 country_unavailable` | No numbers are stocked in this country. | | `502 inventory_unavailable` | The inventory could not be reached, or refused a search it should accept. Retry; do not treat it as no stock. | ## Regulatory Requirements Some numbers require regulatory information (an address, a document, or text) before they can be used. Which ones is decided per number, and the purchase checks it for the exact number before charging anything. Look it up with `GET /v1/phone-numbers/requirements?phoneNumber=%2B...` before buying. A number with requirements is bought with `type` and `regulatoryRequirements`, starts with `regulatoryStatus: pending_review`, and can send messages and place calls once it is `approved`. The [Regulatory Requirements](/guides/phone-numbers/regulatory-requirements) guide covers: * Looking up the requirements for a number * Creating addresses and uploading documents * Including requirements in your purchase request * Reusing information submitted before * Following review and assigning the number to a sender ## Purchasing a Phone Number Once you've found a number you want, purchase it: ```typescript TypeScript theme={null} const phoneNumber = await zavu.phoneNumbers.purchase({ phoneNumber: "+14155551234", name: "Customer Support", }); console.log("Purchased:", phoneNumber.phoneNumber.phoneNumber); console.log("ID:", phoneNumber.phoneNumber.id); ``` ```python Python theme={null} phone_number = zavu.phone_numbers.purchase( phone_number="+14155551234", name="Customer Support" ) print("Purchased:", phone_number.phone_number.phone_number) print("ID:", phone_number.phone_number.id) ``` ```ruby Ruby theme={null} phone_number = client.phone_numbers.purchase( phone_number: "+14155551234", name: "Customer Support" ) puts "Purchased: #{phone_number.phone_number.phone_number}" puts "ID: #{phone_number.phone_number.id}" ``` ```go Go theme={null} phoneNumber, _ := client.PhoneNumbers.Purchase(context.TODO(), zavudev.PhoneNumberPurchaseParams{ PhoneNumber: "+14155551234", Name: zavudev.String("Customer Support"), }) fmt.Println("Purchased:", phoneNumber.PhoneNumber.PhoneNumber) fmt.Println("ID:", phoneNumber.PhoneNumber.ID) ``` ```php PHP theme={null} $phoneNumber = $client->phoneNumbers->purchase(phoneNumber: '+14155551234', name: 'Customer Support'); echo "Purchased: {$phoneNumber->phoneNumber->phoneNumber}\n"; echo "ID: {$phoneNumber->phoneNumber->id}\n"; ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/phone-numbers \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "phoneNumber": "+14155551234", "name": "Customer Support" }' ``` ### Purchase Parameters | Parameter | Type | Required | Description | | - | - | - | - | | `phoneNumber` | string | Yes | Phone number in E.164 format | | `name` | string | No | Custom name for the phone number (max 100 characters) | | `type` | string | With `regulatoryRequirements` | The type the number was found under: `local`, `national`, `mobile`, or `tollFree`. A number not listed under it is refused with `400 number_unavailable` | | `regulatoryRequirements` | array | For numbers with requirements | `{ requirementType, fieldValue }` entries. See [Regulatory Requirements](/guides/phone-numbers/regulatory-requirements). Not in the SDKs yet: use REST | ### Response ```json theme={null} { "phoneNumber": { "id": "pn_abc123", "phoneNumber": "+14155551234", "name": "Customer Support", "capabilities": ["sms", "voice"], "status": "active", "regulatoryStatus": "approved", "pricing": { "monthlyPrice": 1.25, "upfrontCost": 100, "monthlyCost": 125, "isFreeNumber": true }, "nextRenewalDate": "2025-02-15T00:00:00.000Z", "createdAt": "2025-01-15T10:30:00.000Z" } } ``` > [!WARNING] > > Purchasing a phone number deducts the upfront cost and the first month from your account balance. Ensure you have sufficient funds before purchasing. ## Listing Your Phone Numbers View all phone numbers owned by your project: ```typescript TypeScript theme={null} const result = await zavu.phoneNumbers.list(); for (const number of result.items) { console.log(number.id, number.phoneNumber, number.name); console.log("Status:", number.status); } // Filter by status const activeNumbers = await zavu.phoneNumbers.list({ status: "active", }); ``` ```python Python theme={null} result = zavu.phone_numbers.list() for number in result.items: print(number.id, number.phone_number, number.name) print("Status:", number.status) # Filter by status active_numbers = zavu.phone_numbers.list(status="active") ``` ```ruby Ruby theme={null} result = client.phone_numbers.list result.items.each do |number| puts "#{number.id} #{number.phone_number} #{number.name}" puts "Status: #{number.status}" end # Filter by status active_numbers = client.phone_numbers.list(status: "active") ``` ```go Go theme={null} result, _ := client.PhoneNumbers.List(context.TODO(), zavudev.PhoneNumberListParams{}) for _, number := range result.Items { fmt.Println(number.ID, number.PhoneNumber, number.Name) fmt.Println("Status:", number.Status) } // Filter by status activeNumbers, _ := client.PhoneNumbers.List(context.TODO(), zavudev.PhoneNumberListParams{ Status: zavudev.PhoneNumberStatusActive, }) ``` ```php PHP theme={null} $result = $client->phoneNumbers->list(); foreach ($result->items as $number) { echo "{$number->id} {$number->phoneNumber} {$number->name}\n"; echo "Status: {$number->status}\n"; } // Filter by status $activeNumbers = $client->phoneNumbers->list(status: 'active'); ``` ```bash cURL theme={null} curl "https://api.zavu.dev/v1/phone-numbers" \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" # Filter by status curl "https://api.zavu.dev/v1/phone-numbers?status=active" \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` ## Getting Phone Number Details Retrieve details for a specific phone number: ```typescript TypeScript theme={null} const { phoneNumber: number } = await zavu.phoneNumbers.retrieve("pn_abc123"); console.log(number.phoneNumber); console.log("Assigned to sender:", number.senderId); console.log("Next renewal:", number.nextRenewalDate); ``` ```python Python theme={null} number = zavu.phone_numbers.retrieve("pn_abc123").phone_number print(number.phone_number) print("Assigned to sender:", number.sender_id) print("Next renewal:", number.next_renewal_date) ``` ```ruby Ruby theme={null} number = client.phone_numbers.retrieve("pn_abc123").phone_number puts number.phone_number puts "Assigned to sender: #{number.sender_id}" puts "Next renewal: #{number.next_renewal_date}" ``` ```go Go theme={null} res, _ := client.PhoneNumbers.Get(context.TODO(), "pn_abc123") number := res.PhoneNumber fmt.Println(number.PhoneNumber) fmt.Println("Assigned to sender:", number.SenderID) fmt.Println("Next renewal:", number.NextRenewalDate) ``` ```php PHP theme={null} $number = $client->phoneNumbers->retrieve('pn_abc123')->phoneNumber; echo "{$number->phoneNumber}\n"; echo "Assigned to sender: {$number->senderID}\n"; echo "Next renewal: {$number->nextRenewalDate}\n"; ``` ```bash cURL theme={null} curl "https://api.zavu.dev/v1/phone-numbers/pn_abc123" \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` ## Updating a Phone Number Update the name of a phone number: ```typescript TypeScript theme={null} const number = await zavu.phoneNumbers.update("pn_abc123", { name: "Marketing Line", }); ``` ```python Python theme={null} number = zavu.phone_numbers.update( "pn_abc123", name="Marketing Line" ) ``` ```ruby Ruby theme={null} number = client.phone_numbers.update("pn_abc123", name: "Marketing Line" ) ``` ```go Go theme={null} number, _ := client.PhoneNumbers.Update(context.TODO(), "pn_abc123", zavudev.PhoneNumberUpdateParams{ Name: zavudev.String("Marketing Line"), }) ``` ```php PHP theme={null} $number = $client->phoneNumbers->update('pn_abc123', name: 'Marketing Line'); ``` ```bash cURL theme={null} curl -X PATCH https://api.zavu.dev/v1/phone-numbers/pn_abc123 \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Marketing Line" }' ``` ## Assigning to a Sender Connect a phone number to a sender profile for use in messaging. A number still under regulatory review (`regulatoryStatus: pending_review`) can be assigned too: it is connected to the sender when review approves it. A number rejected in review cannot be assigned, and the call answers `400`. ```typescript TypeScript theme={null} const number = await zavu.phoneNumbers.update("pn_abc123", { senderId: "snd_xyz789", }); ``` ```python Python theme={null} number = zavu.phone_numbers.update( "pn_abc123", sender_id="snd_xyz789" ) ``` ```ruby Ruby theme={null} number = client.phone_numbers.update("pn_abc123", sender_id: "snd_xyz789" ) ``` ```go Go theme={null} number, _ := client.PhoneNumbers.Update(context.TODO(), "pn_abc123", zavudev.PhoneNumberUpdateParams{ SenderID: zavudev.String("snd_xyz789"), }) ``` ```php PHP theme={null} $number = $client->phoneNumbers->update('pn_abc123', senderID: 'snd_xyz789'); ``` ```bash cURL theme={null} curl -X PATCH https://api.zavu.dev/v1/phone-numbers/pn_abc123 \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "senderId": "snd_xyz789" }' ``` To unassign a phone number from a sender: ```typescript theme={null} const number = await zavu.phoneNumbers.update("pn_abc123", { senderId: null, }); ``` ## Releasing a Phone Number Release a phone number you no longer need: ```typescript TypeScript theme={null} await zavu.phoneNumbers.release("pn_abc123"); ``` ```python Python theme={null} zavu.phone_numbers.release("pn_abc123") ``` ```ruby Ruby theme={null} client.phone_numbers.release("pn_abc123") ``` ```go Go theme={null} client.PhoneNumbers.Release(context.TODO(), "pn_abc123") ``` ```php PHP theme={null} $client->phoneNumbers->release('pn_abc123'); ``` ```bash cURL theme={null} curl -X DELETE https://api.zavu.dev/v1/phone-numbers/pn_abc123 \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` > [!WARNING] > > You cannot release a phone number that is assigned to a sender. Unassign it first by setting `senderId` to `null`. ## Phone Number Statuses | Status | Description | | - | - | | `active` | Owned and billing. Usable for messaging when `regulatoryStatus` is also `approved` | | `suspended` | Temporarily disabled (e.g., for payment issues) | | `releasing` | Being released; it can no longer send | | `released` | Released; it can no longer send | | `pending` | Legacy. Accepted by the `status` filter, never written to a number today | The SDK `status` enums carry `active`, `suspended` and `pending` only; read `releasing` and `released` from the REST response until their next release. A number on its way out carries `releaseAt`: the date it goes back to the carrier, after a paid plan ended or at the end of the 7-day grace after a renewal could not be paid. Paying, or returning to a paid plan, before that date keeps it. A released number cannot be recovered. ### When a paid plan ends Without a paid plan a team keeps **one** number: * **Which one.** The one chosen in the dashboard when cancelling. Without a choice, the number the plan included; without one, the oldest number the team bought. Numbers you brought yourself are never released. * **How it is paid.** Monthly from the balance, at its listed price. A number the plan included is charged its first month when the plan ends; a bought number keeps its renewal date. A renewal the balance cannot pay suspends it, and it is released at the end of the 7-day grace. * **Every other number** keeps working, unbilled, and is released 7 days after the plan ends. `releaseAt` carries the exact date (00:00 UTC of the day after the window closes). * **Coming back to a paid plan.** Before `releaseAt`, the numbers on their way out stay and bill as paid numbers again. The kept number is the included one again if it was the included one; otherwise it stays a paid number. ## Using Phone Numbers with Channels Phone numbers can be used with different messaging channels: ### SMS Phone numbers are directly used as the sender ID for SMS messages. Simply assign the phone number to a sender and start sending. ### WhatsApp For WhatsApp, you need to register your phone number with WhatsApp Business API. The phone number is used to receive the verification code during WhatsApp Business Account setup. > [!NOTE] > > WhatsApp Business registration requires a phone number that can receive SMS. Your Zavu phone number works perfectly for this. ## Pricing Phone number pricing varies by country and type: | Component | Description | | - | - | | `upfrontPrice` | One-time purchase cost | | `monthlyPrice` | Recurring monthly fee | | `isFreeEligible` | Whether this account can claim this number as the one its paid plan includes. `false` on Free, and once the benefit has been claimed, even for a number that would otherwise qualify | > [!NOTE] > > Prices are displayed in USD. Monthly fees are automatically charged from your account balance. ## Best Practices > [card] Name Your Numbers > > Give descriptive names to phone numbers so you can easily identify their purpose. > [card] Monitor Status > > Regularly check the status of your phone numbers to ensure they're active. > [card] Plan Capacity > > Purchase numbers based on your messaging volume and geographic needs. > [card] Use Local Numbers > > Local numbers often have higher delivery rates than toll-free numbers for certain use cases. ## Next Steps > [card] [Create a Sender](/concepts/senders) > > Connect your phone number to a sender profile > [card] [Send SMS](/guides/sending-messages/sms) > > Start sending SMS messages # Regulatory Requirements Source: https://docs.zavu.dev/guides/phone-numbers/regulatory-requirements Fulfill regulatory requirements for phone number purchases in certain countries Some phone numbers require regulatory information before they can be used: an address, an identity or business document, or text such as a business name. Which numbers do is decided per number by the carrier, not by a fixed list of countries, and the purchase checks it for the exact number you buy, before anything is charged. ## Overview To buy a number with regulatory requirements: 1. **Check requirements** for the number you want to buy 2. **Create addresses** for any `address` requirement 3. **Upload documents** for any `document` requirement 4. **Purchase** the number with its `type`, passing every requirement as `regulatoryRequirements` 5. **Wait for review**: the number is yours immediately with `regulatoryStatus: pending_review`, and can send messages and place calls once it is `approved` 6. **Assign it to a sender**, before or after approval The information is kept for your project under the number's country and type. A later purchase in the same country and type may omit `regulatoryRequirements`, as long as what is kept still covers that number's requirements. ## Checking Requirements Before purchasing, check what requirements apply. Pass `phoneNumber` with a number from `GET /v1/phone-numbers/available` to get the requirements for that exact number, which is what the purchase checks. Encode its `+` as `%2B`. `countryCode` and `type` give the requirements for a country instead: ```bash theme={null} curl "https://api.zavu.dev/v1/phone-numbers/requirements?phoneNumber=%2B4930123456" \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` An empty `items` array means the number needs nothing: buy it normally. When the number's own requirements cannot be resolved, the list for its country and `type` is returned instead, and the purchase checks the same list. If the requirements cannot be retrieved at all, the API answers `502` with code `requirements_unavailable` instead of an empty list: retry rather than purchasing without them. For US and Canadian numbers a purchase does not wait on this lookup: they are sold as numbers without requirements. The SDKs below take `countryCode` and `type`; they have no per-number variant yet: ```typescript TypeScript theme={null} const requirements = await zavu.phoneNumbers.requirements({ countryCode: "DE", type: "local", }); for (const req of requirements.items) { console.log("Requirement:", req.id); for (const type of req.requirementTypes) { console.log(" -", type.name, `(${type.type})`); console.log(" ", type.description); } } ``` ```python Python theme={null} requirements = zavu.phone_numbers.requirements( country_code="DE", type="local" ) for req in requirements.items: print("Requirement:", req.id) for type in req.requirement_types: print(f" - {type.name} ({type.type})") print(f" {type.description}") ``` ```ruby Ruby theme={null} requirements = client.phone_numbers.requirements( country_code: "DE", type: "local" ) requirements.items.each do |req| puts "Requirement: #{req.id}" req.requirement_types.each do |type| puts " - #{type.name} (#{type.type})" puts " #{type.description}" end end ``` ```go Go theme={null} requirements, _ := client.PhoneNumbers.Requirements(context.TODO(), zavudev.PhoneNumberRequirementsParams{ CountryCode: "DE", Type: zavudev.PhoneNumberTypeLocal, }) for _, req := range requirements.Items { fmt.Println("Requirement:", req.ID) for _, t := range req.RequirementTypes { fmt.Printf(" - %s (%s)\n", t.Name, t.Type) fmt.Printf(" %s\n", t.Description) } } ``` ```php PHP theme={null} $requirements = $client->phoneNumbers->requirements(countryCode: 'DE', type: 'local'); foreach ($requirements->items as $req) { echo "Requirement: {$req->id}\n"; foreach ($req->requirementTypes as $type) { echo " - {$type->name} ({$type->type})\n"; echo " {$type->description}\n"; } } ``` ```bash cURL theme={null} curl "https://api.zavu.dev/v1/phone-numbers/requirements?countryCode=DE&type=local" \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` ### Requirement Types | Type | Description | | - | - | | `address` | An address: create one and pass its `id` | | `document` | An identity or business document: upload one and pass its `id` | | `textual` | Text (e.g. a business name): pass the text itself | | `action` | An instruction to follow; send no entry for it | ### Response ```json theme={null} { "items": [ { "id": "+4930123456", "countryCode": "DE", "phoneNumberType": "local", "action": "ordering", "requirementTypes": [ { "id": "8c5b1a2e-0f3d-4f5b-9a61-2c7e4d9b1f10", "name": "Address", "description": "An address within Germany", "type": "address" }, { "id": "3f9e2d41-7b6a-4c1e-8d52-9a0b1c2d3e4f", "name": "Identity Document", "description": "Government-issued ID", "type": "document" } ] } ] } ``` For a `phoneNumber` request, `id` is that phone number. For a country request it identifies the requirement group. `acceptanceCriteria`, when present, carries `allowedValues` on a `phoneNumber` request, and `minLength`, `maxLength` and `allowedValues` on a country request. ## Creating Addresses If an address requirement exists, create an address first: ```typescript TypeScript theme={null} const created = await zavu.addresses.create({ firstName: "John", lastName: "Doe", streetAddress: "Unter den Linden 77", locality: "Berlin", administrativeArea: "Berlin", postalCode: "10117", countryCode: "DE", }); console.log("Address ID:", created.address.id); console.log("Status:", created.address.status); ``` ```python Python theme={null} created = zavu.addresses.create( first_name="John", last_name="Doe", street_address="Unter den Linden 77", locality="Berlin", administrative_area="Berlin", postal_code="10117", country_code="DE" ) print("Address ID:", created.address.id) print("Status:", created.address.status) ``` ```ruby Ruby theme={null} created = client.addresses.create( first_name: "John", last_name: "Doe", street_address: "Unter den Linden 77", locality: "Berlin", administrative_area: "Berlin", postal_code: "10117", country_code: "DE" ) puts "Address ID: #{created.address.id}" puts "Status: #{created.address.status}" ``` ```go Go theme={null} created, _ := client.Addresses.New(context.TODO(), zavudev.AddressNewParams{ FirstName: zavudev.String("John"), LastName: zavudev.String("Doe"), StreetAddress: "Unter den Linden 77", Locality: "Berlin", AdministrativeArea: zavudev.String("Berlin"), PostalCode: "10117", CountryCode: "DE", }) fmt.Println("Address ID:", created.Address.ID) fmt.Println("Status:", created.Address.Status) ``` ```php PHP theme={null} $address = $client->addresses->create( countryCode: 'DE', locality: 'Berlin', postalCode: '10117', streetAddress: 'Unter den Linden 77', administrativeArea: 'Berlin', firstName: 'John', lastName: 'Doe', ); echo "Address ID: {$address->address->id}\n"; echo "Status: {$address->address->status}\n"; ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/addresses \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "firstName": "John", "lastName": "Doe", "streetAddress": "Unter den Linden 77", "locality": "Berlin", "administrativeArea": "Berlin", "postalCode": "10117", "countryCode": "DE" }' ``` ### Address Fields > [!WARNING] > > `firstName` and `lastName` are required. Requests that omit them are refused > with `400 invalid_request`: the carrier will not register an address without > a named person, and an address it refuses cannot be used to buy a number. > The SDKs still type both as optional until their next release, so a call that > leaves them out compiles and fails at runtime. | Field | Type | Required | Description | | - | - | - | - | | `firstName` | string | Yes | First name of the person the address is registered to | | `lastName` | string | Yes | Last name of the person the address is registered to | | `businessName` | string | No | Business name, for a business address. Defaults to the person's full name | | `streetAddress` | string | Yes | Street address line 1 | | `extendedAddress` | string | No | Street address line 2 (apartment, suite, etc.) | | `locality` | string | Yes | City or town | | `administrativeArea` | string | No | State, province, or region | | `postalCode` | string | Yes | Postal or ZIP code | | `countryCode` | string | Yes | Two-letter ISO country code | ### Address Status A new address is returned with `status: pending` and stays that way. You can use its `id` in a purchase right away, in the same project it was created in. Review happens on the purchase, not on the address: follow it with the number's `regulatoryStatus`. An address marked `rejected` is refused in a purchase. ## Uploading Documents If a document requirement exists, upload the required document: ```typescript TypeScript theme={null} // Step 1: Get upload URL const { uploadUrl } = await zavu.regulatoryDocuments.uploadURL(); // Step 2: Upload the file to the returned URL const uploadResponse = await fetch(uploadUrl, { method: "POST", headers: { "Content-Type": "image/jpeg" }, body: fileBuffer, }); const { storageId } = await uploadResponse.json(); // Step 3: Create document record with the storageId const created = await zavu.regulatoryDocuments.create({ name: "John Doe Passport", documentType: "passport", storageId: storageId, mimeType: "image/jpeg", fileSize: fileBuffer.byteLength, }); console.log("Document ID:", created.document.id); console.log("Status:", created.document.status); ``` ```python Python theme={null} # Step 1: Get upload URL upload_result = zavu.regulatory_documents.upload_url() # Step 2: Upload the file to the returned URL import requests with open("passport.jpg", "rb") as f: file_data = f.read() response = requests.post( upload_result.upload_url, headers={"Content-Type": "image/jpeg"}, data=file_data ) storage_id = response.json()["storageId"] # Step 3: Create document record with the storageId created = zavu.regulatory_documents.create( name="John Doe Passport", document_type="passport", storage_id=storage_id, mime_type="image/jpeg", file_size=len(file_data) ) print("Document ID:", created.document.id) print("Status:", created.document.status) ``` ```ruby Ruby theme={null} # Step 1: Get upload URL upload_result = client.regulatory_documents.upload_url # Step 2: Upload the file to the returned URL require "net/http" file_data = File.binread("passport.jpg") uri = URI(upload_result.upload_url) http = Net::HTTP.new(uri.host, uri.port) http.use_ssl = true request = Net::HTTP::Post.new(uri) request["Content-Type"] = "image/jpeg" request.body = file_data response = http.request(request) storage_id = JSON.parse(response.body)["storageId"] # Step 3: Create document record with the storageId created = client.regulatory_documents.create( name: "John Doe Passport", document_type: "passport", storage_id: storage_id, mime_type: "image/jpeg", file_size: file_data.bytesize ) puts "Document ID: #{created.document.id}" puts "Status: #{created.document.status}" ``` ```go Go theme={null} // Step 1: Get upload URL uploadResult, _ := client.RegulatoryDocuments.UploadURL(context.TODO()) // Step 2: Upload the file to the returned URL fileData, _ := os.ReadFile("passport.jpg") req, _ := http.NewRequest("POST", uploadResult.UploadURL, bytes.NewReader(fileData)) req.Header.Set("Content-Type", "image/jpeg") resp, _ := http.DefaultClient.Do(req) var uploadResp struct{ StorageID string `json:"storageId"` } json.NewDecoder(resp.Body).Decode(&uploadResp) // Step 3: Create document record with the storageId created, _ := client.RegulatoryDocuments.New(context.TODO(), zavudev.RegulatoryDocumentNewParams{ Name: "John Doe Passport", DocumentType: zavudev.RegulatoryDocumentNewParamsDocumentTypePassport, StorageID: uploadResp.StorageID, MimeType: "image/jpeg", FileSize: int64(len(fileData)), }) fmt.Println("Document ID:", created.Document.ID) fmt.Println("Status:", created.Document.Status) ``` ```php PHP theme={null} // Step 1: Get upload URL $uploadResult = $client->regulatoryDocuments->uploadURL(); // Step 2: Upload the file to the returned URL $fileData = file_get_contents('passport.jpg'); $ch = curl_init($uploadResult->uploadURL); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $fileData); curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: image/jpeg']); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); $response = json_decode(curl_exec($ch)); curl_close($ch); $storageId = $response->storageId; // Step 3: Create document record with the storageId $document = $client->regulatoryDocuments->create( documentType: 'passport', fileSize: strlen($fileData), mimeType: 'image/jpeg', name: 'John Doe Passport', storageID: $storageId, ); echo "Document ID: {$document->document->id}\n"; echo "Status: {$document->document->status}\n"; ``` ```bash cURL theme={null} # Step 1: Get upload URL curl -X POST https://api.zavu.dev/v1/documents/upload-url \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" # Step 2: Upload file to the returned URL (returns storageId) STORAGE_ID=$(curl -X POST "$UPLOAD_URL" \ -H "Content-Type: image/jpeg" \ --data-binary @passport.jpg | jq -r '.storageId') # Step 3: Create document record curl -X POST https://api.zavu.dev/v1/documents \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d "{ \"name\": \"John Doe Passport\", \"documentType\": \"passport\", \"storageId\": \"$STORAGE_ID\", \"mimeType\": \"image/jpeg\", \"fileSize\": 102400 }" ``` ### Supported Document Types | Type | Description | | - | - | | `passport` | Government-issued passport | | `national_id` | National identity card | | `drivers_license` | Driver's license | | `utility_bill` | Utility bill (for proof of address) | | `tax_id` | Tax identification document | | `business_registration` | Business registration certificate | | `proof_of_address` | Bank statement or official letter | | `other` | Other document type | ### File Requirements The API does not check the file's format or size; the dashboard uploader accepts the following, so stay within it: * **Formats**: JPEG, PNG, or PDF * **Max size**: 10MB * **Quality**: Clear, readable scan or photo * **Validity**: Document must not be expired ### Document Status A new document is returned with `status: uploaded` and stays that way. You can use its `id` in a purchase right away, in the project it was uploaded to. Review happens on the purchase, not on the document: follow it with the number's `regulatoryStatus`. A document marked `rejected` is refused in a purchase. ## Purchasing with Requirements Pass one entry per requirement except `action` ones: `requirementType` is the `requirementTypes[].id` from the requirements response, and `fieldValue` is the address `id`, the document `id`, or the text. `type` is required alongside, the type you searched the number under. Every required id must be present, once, no unknown id may be sent, and addresses and documents must belong to the project making the purchase and must not be rejected. Otherwise the purchase is refused with `400 invalid_request`, naming the requirement, before anything is charged. The same happens if an address or document cannot be registered for review. ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/phone-numbers \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "phoneNumber": "+4930123456", "name": "Germany Line", "type": "local", "regulatoryRequirements": [ { "requirementType": "8c5b1a2e-0f3d-4f5b-9a61-2c7e4d9b1f10", "fieldValue": "
" }, { "requirementType": "3f9e2d41-7b6a-4c1e-8d52-9a0b1c2d3e4f", "fieldValue": "" } ] }' ``` > [!NOTE] > > `regulatoryRequirements` and `type` are new on this endpoint and not yet in the SDKs. Use the REST call above until the SDKs are updated. The response is the purchased number. It is billed from purchase, and cannot send messages or place calls until review approves it: ```json theme={null} { "phoneNumber": { "id": "pn_abc123", "phoneNumber": "+4930123456", "status": "active", "regulatoryStatus": "pending_review" } } ``` The status is re-checked every 6 hours. Poll `GET /v1/phone-numbers/{phoneNumberId}` until `regulatoryStatus` is `approved` or `rejected`. ## Assigning a Sender Assign the number to a sender to use it, before or after approval: ```bash cURL theme={null} curl -X PATCH https://api.zavu.dev/v1/phone-numbers/pn_abc123 \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "senderId": "snd_abc123" }' ``` Assigned while `pending_review`, the number is recorded on the sender and connected when it is approved; if that connection fails it is retried until it succeeds. A sender created over the API is set up for SMS as part of the assignment. A `rejected` number cannot be assigned: the call answers `400`. A number that stays `pending_review` for long may be waiting on information the API cannot supply: contact support. ## Reusing Submitted Information If the number needs regulatory information and the request carries none, the purchase reuses what your project submitted before for the same country and type, but only when it still covers every requirement of the new number and every address and document in it belongs to the project. Otherwise it is refused with `400 regulatory_compliance_required`, listing the missing requirements in `details.missingRequirements`, and nothing is charged. ## Managing Addresses ### List Addresses ```typescript TypeScript theme={null} const result = await zavu.addresses.list(); for (const address of result.items) { console.log(address.id, address.streetAddress, address.status); } ``` ```python Python theme={null} result = zavu.addresses.list() for address in result.items: print(address.id, address.street_address, address.status) ``` ```ruby Ruby theme={null} result = client.addresses.list result.items.each do |address| puts "#{address.id} #{address.street_address} #{address.status}" end ``` ```go Go theme={null} result, _ := client.Addresses.List(context.TODO(), zavudev.AddressListParams{}) for _, address := range result.Items { fmt.Println(address.ID, address.StreetAddress, address.Status) } ``` ```php PHP theme={null} $result = $client->addresses->list(); foreach ($result->items as $address) { echo "{$address->id} {$address->streetAddress} {$address->status}\n"; } ``` ```bash cURL theme={null} curl "https://api.zavu.dev/v1/addresses" \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` ### Delete Address ```typescript TypeScript theme={null} await zavu.addresses.delete("addr_abc123"); ``` ```python Python theme={null} zavu.addresses.delete("addr_abc123") ``` ```ruby Ruby theme={null} client.addresses.delete("addr_abc123") ``` ```go Go theme={null} client.Addresses.Delete(context.TODO(), "addr_abc123") ``` ```php PHP theme={null} $client->addresses->delete('addr_abc123'); ``` ```bash cURL theme={null} curl -X DELETE https://api.zavu.dev/v1/addresses/addr_abc123 \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` ## Managing Documents ### List Documents ```typescript TypeScript theme={null} const result = await zavu.regulatoryDocuments.list(); for (const doc of result.items) { console.log(doc.id, doc.name, doc.status); } ``` ```python Python theme={null} result = zavu.regulatory_documents.list() for doc in result.items: print(doc.id, doc.name, doc.status) ``` ```ruby Ruby theme={null} result = client.regulatory_documents.list result.items.each do |doc| puts "#{doc.id} #{doc.name} #{doc.status}" end ``` ```go Go theme={null} result, _ := client.RegulatoryDocuments.List(context.TODO(), zavudev.RegulatoryDocumentListParams{}) for _, doc := range result.Items { fmt.Println(doc.ID, doc.Name, doc.Status) } ``` ```php PHP theme={null} $result = $client->regulatoryDocuments->list(); foreach ($result->items as $doc) { echo "{$doc->id} {$doc->name} {$doc->status}\n"; } ``` ```bash cURL theme={null} curl "https://api.zavu.dev/v1/documents" \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` ### Delete Document ```typescript TypeScript theme={null} await zavu.regulatoryDocuments.delete("doc_xyz789"); ``` ```python Python theme={null} zavu.regulatory_documents.delete("doc_xyz789") ``` ```ruby Ruby theme={null} client.regulatory_documents.delete("doc_xyz789") ``` ```go Go theme={null} client.RegulatoryDocuments.Delete(context.TODO(), "doc_xyz789") ``` ```php PHP theme={null} $client->regulatoryDocuments->delete('doc_xyz789'); ``` ```bash cURL theme={null} curl -X DELETE https://api.zavu.dev/v1/documents/doc_xyz789 \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` ## Country-Specific Requirements Requirements differ by country and number type, and change with regulation. The requirements endpoint is the source of truth: query it for the number you are about to buy rather than relying on a fixed list. ## Best Practices > [card] Prepare Documents First > > Create addresses and upload documents before the purchase call, so it carries every requirement. > [card] Follow the Review > > A regulated number is billed from purchase but usable only once `regulatoryStatus` is `approved`. > [card] Keep Documents Current > > Ensure documents are not expired and information is up to date. > [card] Check Requirements Early > > Query requirements during your planning phase to avoid purchase delays. ## Error Handling | Status | Code | Description | | - | - | - | | `400` | `regulatory_compliance_required` | The number needs regulatory information and none was provided or reusable. `details.missingRequirements` lists what to send | | `400` | `invalid_request` | Malformed request, a missing, unknown or repeated requirement id, an address or document from another project or rejected in review, one that could not be registered for review, `type` missing with `regulatoryRequirements`, or requirements that cannot be looked up for this input | | `400` | `number_unavailable` | The number is no longer available, or is not listed under the `type` sent | | `502` | `requirements_unavailable` | The requirements lookup failed. Retry; it does not mean nothing is required. A purchase refused this way is not charged. A purchase of a US or Canadian number does not return it, because it goes ahead as a number without requirements; this endpoint returns it for any country | ## Next Steps > [card] [Purchase Phone Numbers](/guides/phone-numbers/purchasing) > > Learn about the full purchase process > [card] [Create Senders](/concepts/senders) > > Set up senders to start messaging # Event Types Source: https://docs.zavu.dev/guides/receiving-messages/events Complete reference for all webhook event types and their payloads Zavu sends webhook events for message lifecycle stages. There are several categories: | Category | Events | Purpose | | - | - | - | | **Inbound** | `conversation.new`, `message.inbound`, `message.unsupported`, `message.reaction` | Receive messages, reactions, and new contact notifications | | **Contacts** | `contact.identity_linked` | Know when a WhatsApp contact's BSUID and phone number were tied together | | **Outbound** | `message.queued`, `message.sent`, `message.delivered`, `message.failed` | Track your outbound message delivery | | **Templates** | `template.status_changed` | Track WhatsApp template approval status | | **Broadcasts** | `broadcast.status_changed` | Track broadcast delivery lifecycle | | **Invitations** | `invitation.status_changed` | Track partner invitation status changes | ## Event Structure All events follow this structure: ```json theme={null} { "id": "evt_1705312200000_abc123", "type": "message.inbound", "timestamp": 1705312200000, "senderId": "snd_abc123", "projectId": "prj_xyz789", "data": { // Event-specific payload } } ``` *** ## Inbound Events ## conversation.new Triggered when a new contact sends you their first message. This is useful for tracking new leads or customers. ```json theme={null} { "id": "evt_1705312200000_new123", "type": "conversation.new", "timestamp": 1705312200000, "senderId": "snd_abc123", "projectId": "prj_xyz789", "data": { "phoneNumber": "+14155551234", "channel": "whatsapp", "firstMessageId": "msg_xyz789", "firstMessageText": "Hi, I'm interested in your product", "profileName": "John Doe" } } ``` ### Data Fields | Field | Type | Description | | - | - | - | | `phoneNumber` | string | New contact's phone number (E.164 format) | | `channel` | string | Channel used: `sms` or `whatsapp` | | `firstMessageId` | string | ID of the first message | | `firstMessageText` | string | Content of the first message | | `profileName` | string \| null | Contact's WhatsApp profile name. `null` for SMS. | > [!NOTE] > > You'll also receive a `message.inbound` event for the same message. Use `conversation.new` specifically for new lead notifications or CRM integrations. *** ## message.inbound Triggered when a customer sends you a message on any channel: SMS, WhatsApp, Telegram, Instagram, Messenger or email. This includes text, images, videos, audio, documents, stickers, locations, and contacts. ### Text Message Example ```json theme={null} { "id": "evt_1705312200000_abc123", "type": "message.inbound", "timestamp": 1705312200000, "senderId": "snd_abc123", "projectId": "prj_xyz789", "data": { "messageId": "msg_xyz789", "from": "+14155551234", "to": "+13125559876", "channel": "whatsapp", "messageType": "text", "text": "Hi, I have a question about my order", "profileName": "John Doe", "providerTimestamp": 1705312199000 } } ``` ### Username Contact (BSUID) Example WhatsApp users can adopt a username and hide their phone number. Their messages arrive through the same event, but `from` carries a **business-scoped user ID (BSUID)** instead of an E.164 phone number: ```json theme={null} { "id": "evt_1705312200000_bsu123", "type": "message.inbound", "timestamp": 1705312200000, "senderId": "snd_abc123", "projectId": "prj_xyz789", "data": { "messageId": "msg_bsu789", "conversationId": "js723987cyghwqxxaxcf590qd18axd95", "contactId": "jd7x2k3m4n5p6q7r8s9t0abc", "whatsapp": { "bsuid": "US.13491208655302741918", "username": "sheena" }, "from": "US.13491208655302741918", "to": "+13125559876", "channel": "whatsapp", "messageType": "text", "text": "Hi, I saw your listing", "profileName": "Sheena", "providerTimestamp": 1705312199000 } } ``` > [!WARNING] > > Do not assume `from` is a phone number, and do not use it as the key for a person: it is the phone number when WhatsApp includes it and the BSUID otherwise, so it can change for the same contact. Key on `contactId`. To reply, pass `from` back as `to` in the send API, which accepts BSUIDs directly. See [Usernames & BSUIDs](/guides/whatsapp/usernames) for the full picture. ### Image Message Example ```json theme={null} { "id": "evt_1705312200000_img456", "type": "message.inbound", "timestamp": 1705312200000, "senderId": "snd_abc123", "projectId": "prj_xyz789", "data": { "messageId": "msg_img789", "from": "+14155551234", "to": "+13125559876", "channel": "whatsapp", "messageType": "image", "text": "Check out this product", "content": { "mediaId": "1234567890", "mimeType": "image/jpeg" }, "profileName": "John Doe" } } ``` > [!NOTE] > > For media messages (image, video, audio, document, sticker), the `content.mediaId` is provided initially. The media is then downloaded and stored, and subsequent reads of the message will include a `content.mediaUrl` with the permanent URL. ### Location Message Example ```json theme={null} { "id": "evt_1705312200000_loc789", "type": "message.inbound", "timestamp": 1705312200000, "senderId": "snd_abc123", "projectId": "prj_xyz789", "data": { "messageId": "msg_loc123", "from": "+14155551234", "to": "+13125559876", "channel": "whatsapp", "messageType": "location", "text": "", "content": { "latitude": 37.7749, "longitude": -122.4194, "name": "San Francisco", "address": "San Francisco, CA, USA" }, "profileName": "John Doe" } } ``` > [!NOTE] > > When the location answers a [location request](/guides/whatsapp/messages/location-request) you sent, the same event carries `content.replyToMessageId` pointing at the request. There is no separate event type — match on that field to tie the coordinates back to the order, ticket or job you were asking about. ### Contact Message Example ```json theme={null} { "id": "evt_1705312200000_cnt456", "type": "message.inbound", "timestamp": 1705312200000, "senderId": "snd_abc123", "projectId": "prj_xyz789", "data": { "messageId": "msg_cnt789", "from": "+14155551234", "to": "+13125559876", "channel": "whatsapp", "messageType": "contact", "text": "", "content": { "contacts": [ { "name": "Jane Smith", "phones": [ { "phone": "+14155559999", "type": "MOBILE" } ], "emails": [ { "email": "jane@example.com", "type": "WORK" } ] } ] }, "profileName": "John Doe" } } ``` ### Interactive Reply Example When a user clicks a button or selects a list item that you sent, you'll receive the reply with the button/item ID: ```json theme={null} { "id": "evt_1705312200000_int123", "type": "message.inbound", "timestamp": 1705312200000, "senderId": "snd_abc123", "projectId": "prj_xyz789", "data": { "messageId": "msg_int456", "from": "+14155551234", "to": "+13125559876", "channel": "whatsapp", "messageType": "text", "text": "Yes, I'm interested", "content": { "interactiveReply": { "type": "button_reply", "id": "btn_interested", "title": "Yes, I'm interested" } }, "profileName": "John Doe" } } ``` > [!NOTE] > > The `interactiveReply.id` is the ID you specified when sending the button or list message. Use this to identify which option the user selected. ### Reply / Quote Context Example When a customer replies to (quotes) an earlier message, the `content` object carries the reply context so you can thread the conversation. When the quoted message exists in Zavu, you get its Zavu `replyToMessageId`, a text snippet, and its type: ```json theme={null} { "id": "evt_1705312300000_rep123", "type": "message.inbound", "timestamp": 1705312300000, "senderId": "snd_abc123", "projectId": "prj_xyz789", "data": { "messageId": "msg_reply999", "from": "+14155551234", "to": "+13125559876", "channel": "whatsapp", "messageType": "text", "text": "Yes, that order is correct", "content": { "replyToMessageId": "msg_original123", "replyToProviderMessageId": "wamid.HBgL...ORIGINAL123", "replyToFrom": "+13125559876", "replyToText": "Your order #ORD-12345 has shipped.", "replyToMessageType": "text" }, "profileName": "John Doe" } } ``` If the quoted message is not stored in Zavu (e.g. an old or unknown message), the context degrades gracefully — you still get the provider ID and sender, but `replyToMessageId`, `replyToText`, and `replyToMessageType` are omitted: ```json theme={null} { "id": "evt_1705312400000_rep456", "type": "message.inbound", "timestamp": 1705312400000, "senderId": "snd_abc123", "projectId": "prj_xyz789", "data": { "messageId": "msg_reply888", "from": "+14155551234", "to": "+13125559876", "channel": "whatsapp", "messageType": "text", "text": "Replying to an old message", "content": { "replyToProviderMessageId": "wamid.HBgL...OLD_UNKNOWN", "replyToFrom": "+13125559876" }, "profileName": "John Doe" } } ``` > [!NOTE] > > Use `replyToMessageId` to link the reply to a message in your system. When it's absent, fall back to `replyToProviderMessageId` (the WhatsApp WAMID), which is always present on a reply. ### Data Fields | Field | Type | Description | | - | - | - | | `messageId` | string | Unique message identifier | | `conversationId` | string | The inbox thread this message was filed in. Always set: the webhook is sent once the message is in its thread, so the first message of a new thread carries the same id as `conversation.new`. Absent on reactions. | | `contactId` | string \| null | The Zavu contact. Stays the same when a WhatsApp contact's `from` switches between BSUID and phone number, unless that switch reveals two contacts for one person: they are merged, and `contact.identity_linked` names the one that went away in `mergedContactId`. Absent on reactions. | | `whatsapp` | object | WhatsApp only, when the BSUID is known: `{ bsuid, username? }`. | | `from` | string | Customer's phone number (E.164 format), or their [BSUID](/guides/whatsapp/usernames) (e.g. `US.13491208655302741918`) for WhatsApp contacts who adopted a username and hid their number | | `to` | string | Your Sender's phone number | | `channel` | string | `sms`, `whatsapp`, `telegram`, `instagram`, `email`, etc. | | `messageType` | string | Message type: `text`, `image`, `video`, `audio`, `document`, `sticker`, `location`, `contact` | | `text` | string | Message text or media caption (also button/list reply title for interactive messages) | | `content` | object \| null | Additional content (for non-text messages) | | `content.mediaId` | string | Media ID from WhatsApp (media messages) | | `content.mimeType` | string | MIME type of the media | | `content.filename` | string | Filename (for documents) | | `content.latitude` | number | Latitude coordinate (location messages) | | `content.longitude` | number | Longitude coordinate (location messages) | | `content.name` | string | Location name (location messages) | | `content.address` | string | Location address (location messages) | | `content.contacts` | array | Contact cards: `{ name, phones: string[] }[]` | | `content.interactiveReply` | object | Interactive reply details (when user clicks a button or list item) | | `content.interactiveReply.type` | string | `button_reply` or `list_reply` | | `content.interactiveReply.id` | string | ID of the button/list item clicked | | `content.interactiveReply.title` | string | Title of the button/list item clicked | | `content.replyToMessageId` | string | Zavu ID of the quoted message. Only present when the quoted message exists in Zavu. | | `content.replyToProviderMessageId` | string | Provider message ID (WhatsApp WAMID) of the quoted message. Always present on a reply. | | `content.replyToFrom` | string | Sender of the quoted message (E.164 format). | | `content.replyToText` | string | Truncated snippet of the quoted message text (≤200 chars). Empty when the quoted message has no text (e.g. media). | | `content.replyToMessageType` | string | Type of the quoted message (`text`, `image`, `video`, etc.). | | `profileName` | string \| null | Sender's WhatsApp profile name. `null` for SMS. | | `providerTimestamp` | number \| null | Provider's original receive time (Unix ms) — when the channel received the message from the contact. Set for WhatsApp, Telegram, Instagram, and Messenger; `null` for SMS and email. Compare against the top-level `timestamp` (dispatch time) to detect delayed deliveries. | | `heldAt` | number | Present only on a message that arrived while the monthly allowance was spent and was delivered later, once the team paid: when it was held (Unix ms). See [sending limits](/concepts/sending-limits). Absent on every other message. | > [!NOTE] > > The top-level `timestamp` is when **Zavu dispatched the webhook**, not when the contact sent the message. If a channel delays delivery, these can differ by minutes. Use `data.providerTimestamp` to measure the real delay and skip stale messages: > > ```typescript theme={null} > const delaySeconds = (event.timestamp - event.data.providerTimestamp) / 1000 > if (event.data.providerTimestamp && delaySeconds > 120) { > // Message arrived more than 2 minutes late — likely stale, skip it. > return > } > ``` > > A message carrying `data.heldAt` was held at the allowance and delivered once you paid, so it can be days old. The same check applies; `heldAt` tells you why it is late. ### Example Handler ```typescript TypeScript theme={null} async function handleInboundMessage(data) { const { from, text, channel } = data; // Store the message await db.messages.create({ from, text, channel, receivedAt: new Date() }); // Send auto-reply if needed if (text.toLowerCase().includes("help")) { await zavu.messages.send({ to: from, text: "Thanks for reaching out! A support agent will contact you shortly." }); } } ``` ```python Python theme={null} async def handle_inbound_message(data): from_number = data["from"] text = data["text"] channel = data["channel"] # Store the message await db.messages.create( from_number=from_number, text=text, channel=channel, received_at=datetime.now() ) # Send auto-reply if needed if "help" in text.lower(): await zavu.messages.send( to=from_number, text="Thanks for reaching out! A support agent will contact you shortly." ) ``` ```ruby Ruby theme={null} def handle_inbound_message(data) from_number = data["from"] text = data["text"] channel = data["channel"] # Store the message Message.create!( from_number: from_number, text: text, channel: channel, received_at: Time.now ) # Send auto-reply if needed if text.downcase.include?("help") client.messages.send_( to: from_number, text: "Thanks for reaching out! A support agent will contact you shortly." ) end end ``` ```go Go theme={null} func handleInboundMessage(data map[string]interface{}) { from := data["from"].(string) text := data["text"].(string) // Store the message db.Messages.Create(from, text, data["channel"].(string)) // Send auto-reply if needed if strings.Contains(strings.ToLower(text), "help") { client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String(from), Text: zavudev.String("Thanks for reaching out! A support agent will contact you shortly."), }) } } ``` ```php PHP theme={null} $from, 'text' => $text, 'channel' => $channel, 'received_at' => now(), ]); // Send auto-reply if needed if (str_contains(strtolower($text), 'help')) { $client->messages->send([ 'to' => $from, 'text' => 'Thanks for reaching out! A support agent will contact you shortly.', ]); } } ``` *** ## message.unsupported Triggered when a customer sends a message type that cannot be fully represented on the channel. On **WhatsApp Cloud API**, this includes polls, deleted messages, ephemeral messages, and some third-party stickers. ```json theme={null} { "id": "evt_1705312200000_unsup123", "type": "message.unsupported", "timestamp": 1705312200000, "senderId": "snd_abc123", "projectId": "prj_xyz789", "data": { "messageId": "msg_xyz789", "from": "+14155551234", "to": "+13125559876", "channel": "whatsapp", "timestamp": 1705312200000 } } ``` ### Data Fields | Field | Type | Description | | - | - | - | | `messageId` | string | Unique message identifier | | `conversationId` | string | The inbox thread this message was filed in. Always set: the webhook is sent once the message is in its thread, so the first message of a new thread carries the same id as `conversation.new`. Absent on reactions. | | `contactId` | string \| null | The Zavu contact. Stays the same when a WhatsApp contact's `from` switches between BSUID and phone number, unless that switch reveals two contacts for one person: they are merged, and `contact.identity_linked` names the one that went away in `mergedContactId`. Absent on reactions. | | `whatsapp` | object | WhatsApp only, when the BSUID is known: `{ bsuid, username? }`. | | `from` | string | Customer's phone number (E.164 format), or their [BSUID](/guides/whatsapp/usernames) (e.g. `US.13491208655302741918`) for WhatsApp contacts who adopted a username and hid their number | | `to` | string | Your Sender's phone number | | `channel` | string | Always `whatsapp` for unsupported messages | | `timestamp` | number | Unix timestamp when message was received | > [!NOTE] > > On WhatsApp Cloud API, unsupported types typically include: > > * **Polls** — Cloud API does not expose poll content > * **Deleted messages** — messages deleted by the sender > * **Ephemeral messages** — disappearing messages that Cloud API does not expose > * **Third-party stickers** — stickers not meeting WhatsApp specifications > > These Cloud API limitations are from Meta, not Zavu. ### Example Handler ```typescript TypeScript theme={null} async function handleUnsupportedMessage(data) { const { from, messageId } = data; // Log unsupported message for analytics await db.unsupportedMessages.create({ messageId, from, receivedAt: new Date() }); // Optionally notify the customer await zavu.messages.send({ to: from, text: "We received your message, but this message type is not supported. Please send a text message instead." }); } ``` ```python Python theme={null} async def handle_unsupported_message(data): from_number = data["from"] message_id = data["messageId"] # Log unsupported message for analytics await db.unsupported_messages.create( message_id=message_id, from_number=from_number, received_at=datetime.now() ) # Optionally notify the customer await zavu.messages.send( to=from_number, text="We received your message, but this message type is not supported. Please send a text message instead." ) ``` ```ruby Ruby theme={null} def handle_unsupported_message(data) from_number = data["from"] message_id = data["messageId"] # Log unsupported message for analytics UnsupportedMessage.create!( message_id: message_id, from_number: from_number, received_at: Time.now ) # Optionally notify the customer client.messages.send_( to: from_number, text: "We received your message, but this message type is not supported. Please send a text message instead." ) end ``` ```go Go theme={null} func handleUnsupportedMessage(data map[string]interface{}) { from := data["from"].(string) messageID := data["messageId"].(string) // Log unsupported message for analytics db.UnsupportedMessages.Create(messageID, from) // Optionally notify the customer client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String(from), Text: zavudev.String("We received your message, but this message type is not supported. Please send a text message instead."), }) } ``` ```php PHP theme={null} $messageId, 'from_number' => $from, 'received_at' => now(), ]); // Optionally notify the customer $client->messages->send([ 'to' => $from, 'text' => 'We received your message, but this message type is not supported. Please send a text message instead.', ]); } ``` *** ## message.reaction Triggered when a customer reacts to a message with an emoji (WhatsApp only). This event is sent when a reaction is added or removed from a message. ```json theme={null} { "id": "evt_1705312200000_react123", "type": "message.reaction", "timestamp": 1705312200000, "senderId": "snd_abc123", "projectId": "prj_xyz789", "data": { "messageId": "msg_xyz789", "providerMessageId": "wamid.HBgLMTU1NT...", "reaction": { "emoji": "👍", "from": "+14155551234", "timestamp": 1705312200000, "action": "added" } } } ``` ### Data Fields | Field | Type | Description | | - | - | - | | `messageId` | string | ID of the message that was reacted to | | `providerMessageId` | string | WhatsApp message ID | | `reaction.emoji` | string | The emoji used for the reaction | | `reaction.from` | string | Phone number of the person who reacted | | `reaction.timestamp` | number | Unix timestamp of the reaction | | `reaction.action` | string | `added` when reaction is added, `removed` when reaction is removed | > [!NOTE] > > When a user removes a reaction, `action` will be `removed` and `emoji` will be an empty string. ### Example Handler ```typescript TypeScript theme={null} async function handleReaction(data) { const { messageId, reaction } = data; const { emoji, from, action } = reaction; if (action === "added") { // Store the reaction await db.reactions.create({ messageId, emoji, from, createdAt: new Date() }); // Track engagement metrics await analytics.track("message_reaction", { messageId, emoji, }); } else if (action === "removed") { // Remove the reaction await db.reactions.delete({ messageId, from, }); } } ``` ```python Python theme={null} async def handle_reaction(data): message_id = data["messageId"] reaction = data["reaction"] emoji = reaction["emoji"] from_number = reaction["from"] action = reaction["action"] if action == "added": # Store the reaction await db.reactions.create( message_id=message_id, emoji=emoji, from_number=from_number, created_at=datetime.now() ) # Track engagement metrics await analytics.track("message_reaction", { "message_id": message_id, "emoji": emoji }) elif action == "removed": # Remove the reaction await db.reactions.delete( message_id=message_id, from_number=from_number ) ``` ```ruby Ruby theme={null} def handle_reaction(data) message_id = data["messageId"] reaction = data["reaction"] emoji = reaction["emoji"] from_number = reaction["from"] action = reaction["action"] if action == "added" # Store the reaction Reaction.create!( message_id: message_id, emoji: emoji, from_number: from_number, created_at: Time.now ) # Track engagement metrics Analytics.track("message_reaction", { message_id: message_id, emoji: emoji }) elsif action == "removed" # Remove the reaction Reaction.where(message_id: message_id, from_number: from_number).destroy_all end end ``` ```go Go theme={null} func handleReaction(data map[string]interface{}) { messageID := data["messageId"].(string) reaction := data["reaction"].(map[string]interface{}) emoji := reaction["emoji"].(string) from := reaction["from"].(string) action := reaction["action"].(string) if action == "added" { // Store the reaction db.Reactions.Create(messageID, emoji, from) // Track engagement metrics analytics.Track("message_reaction", map[string]string{ "messageId": messageID, "emoji": emoji, }) } else if action == "removed" { // Remove the reaction db.Reactions.Delete(messageID, from) } } ``` ```php PHP theme={null} $messageId, 'emoji' => $emoji, 'from_number' => $from, 'created_at' => now(), ]); // Track engagement metrics Analytics::track('message_reaction', [ 'message_id' => $messageId, 'emoji' => $emoji, ]); } elseif ($action === 'removed') { // Remove the reaction Reaction::where('message_id', $messageId) ->where('from_number', $from) ->delete(); } } ``` *** ## contact.identity\_linked Fired when a WhatsApp contact known by its business-scoped user ID (BSUID) is tied to a phone number, a contact known by phone receives a BSUID, or a contact changes their number. It fires once per link, not on every message. Subscribe to it on the sender's webhook. ```json theme={null} { "id": "evt_1705312300000_def456", "type": "contact.identity_linked", "timestamp": 1705312300000, "senderId": "snd_abc123", "projectId": "prj_xyz789", "data": { "contactId": "jd7x2k3m4n5p6q7r8s9t0abc", "bsuid": "US.13491208655302741918", "phoneNumber": "+13125551234", "reason": "provider_reported" } } ``` ### Data Fields | Field | Type | Description | | - | - | - | | `contactId` | string | The Zavu contact. If two contacts were merged, the one that remains. | | `mergedContactId` | string | Only when two contacts were merged: the contact that was folded into `contactId`. Move anything you stored under it to `contactId`. | | `bsuid` | string \| null | The contact's business-scoped user ID. `null` only on `phone_changed`, for a contact WhatsApp never reported a BSUID for | | `phoneNumber` | string | The contact's phone number (E.164 format) | | `reason` | string | `provider_reported` (WhatsApp sent phone and BSUID together on an inbound message), `contact_shared` (the contact answered a `request_contact_info` message), `phone_changed` (the contact changed their number), or `bsuid_changed` (WhatsApp reported a new BSUID for the same number) | | `previousPhoneNumber` | string | Only with `phone_changed`: the number the contact had before | | `previousBsuid` | string | A BSUID you may have stored that no longer identifies this contact: the one it had before, or the merged contact's when the remaining contact keeps its own | After this event, `from` on `message.inbound` is still the BSUID whenever WhatsApp leaves the phone number out of a message. Use `contactId` to recognise the person. See [Usernames & BSUIDs](/guides/whatsapp/usernames). *** ## Outbound Events These events help you track the delivery status of messages you send. Subscribe to these if you need delivery confirmations or failure notifications. ## message.queued Triggered when your message is accepted and queued for delivery. ```json theme={null} { "id": "evt_1705312200000_abc456", "type": "message.queued", "timestamp": 1705312200000, "senderId": "snd_abc123", "projectId": "prj_xyz789", "data": { "messageId": "msg_abc123", "to": "+14155551234", "channel": "sms", "status": "queued" } } ``` ### Data Fields | Field | Type | Description | | - | - | - | | `messageId` | string | Your message identifier | | `to` | string | Recipient's phone number | | `channel` | string | `sms` or `whatsapp` | | `status` | string | Always `queued` for this event | *** ## message.sent Triggered when your outbound message is successfully accepted by the carrier or Meta. This confirms the message has left Zavu's systems and is being processed by the provider. ```json theme={null} { "id": "evt_1705312200000_def456", "type": "message.sent", "timestamp": 1705312200000, "senderId": "snd_abc123", "projectId": "prj_xyz789", "data": { "messageId": "msg_abc123", "to": "+14155551234", "channel": "sms", "status": "sent" } } ``` ### Data Fields | Field | Type | Description | | - | - | - | | `messageId` | string | Your message identifier | | `to` | string | Recipient's phone number | | `channel` | string | `sms` or `whatsapp` | | `status` | string | Always `sent` for this event | ### WhatsApp Business App Echo (Coexistence) When using WhatsApp in coexistence mode, messages sent directly from the WhatsApp Business App also trigger `message.sent` events. These events include the full message content so you can sync outbound messages that were not sent through the Zavu API. You can distinguish these from regular status updates by checking the `source` field. ```json theme={null} { "id": "evt_1705312200000_echo456", "type": "message.sent", "timestamp": 1705312200000, "senderId": "snd_abc123", "projectId": "prj_xyz789", "data": { "messageId": "msg_echo789", "from": "+13125559876", "to": "+14155551234", "channel": "whatsapp", "messageType": "text", "status": "delivered", "text": "Hi! Thanks for reaching out. We'll get back to you shortly.", "source": "whatsapp_business_app", "direction": "outbound" } } ``` #### Additional Fields (Coexistence Only) | Field | Type | Description | | - | - | - | | `from` | string | Your WhatsApp Business phone number | | `messageType` | string | Message type: `text`, `image`, `video`, etc. | | `text` | string | Message content or caption | | `source` | string | Always `whatsapp_business_app` for echo messages | | `direction` | string | Always `outbound` for echo messages | > [!NOTE] > > When you receive a `message.sent` event with `source: "whatsapp_business_app"`, you should create a new outbound message record in your system rather than treating it as a status update. These messages were sent outside of Zavu's API and have their own unique `messageId`. *** ## message.delivered Triggered when your message is confirmed delivered to the recipient's device. ```json theme={null} { "id": "evt_1705312200000_ghi789", "type": "message.delivered", "timestamp": 1705312200000, "senderId": "snd_abc123", "projectId": "prj_xyz789", "data": { "messageId": "msg_abc123", "to": "+14155551234", "channel": "whatsapp", "status": "delivered" } } ``` ### Data Fields | Field | Type | Description | | - | - | - | | `messageId` | string | Your message identifier | | `to` | string | Recipient's phone number | | `channel` | string | `sms` or `whatsapp` | | `status` | string | Always `delivered` for this event | > [!NOTE] > > Delivery confirmation availability depends on the carrier and channel. WhatsApp provides reliable delivery receipts, while SMS delivery confirmations vary by carrier. *** ## message.failed Triggered when message delivery fails. ```json theme={null} { "id": "evt_1705312200000_jkl012", "type": "message.failed", "timestamp": 1705312200000, "senderId": "snd_abc123", "projectId": "prj_xyz789", "data": { "messageId": "msg_abc123", "to": "+14155551234", "channel": "sms", "status": "failed", "errorCode": "30003", "errorMessage": "Unreachable destination handset" } } ``` ### Data Fields | Field | Type | Description | | - | - | - | | `messageId` | string | Your message identifier | | `to` | string | Recipient's phone number | | `channel` | string | `sms` or `whatsapp` | | `status` | string | Always `failed` for this event | | `errorCode` | string | Error code from carrier/provider | | `errorMessage` | string | Human-readable error description | ### Common Error Codes | Code | Description | Suggested Action | | - | - | - | | `30003` | Unreachable destination | Number may be invalid or disconnected | | `30005` | Unknown destination | Verify the phone number format | | `30006` | Landline not supported | SMS cannot be sent to landlines | | `30007` | Carrier rejected | Message blocked by carrier | | `21610` | Unsubscribed recipient | User opted out of messages | ### Example Handler ```typescript TypeScript theme={null} async function handleDeliveryFailure(data) { const { messageId, errorCode, errorMessage } = data; // Log the failure await db.messages.update(messageId, { status: "failed", errorCode, errorMessage, failedAt: new Date() }); // Alert your team for critical failures if (["30007", "21610"].includes(errorCode)) { await alertTeam({ type: "message_blocked", messageId, reason: errorMessage }); } } ``` ```python Python theme={null} async def handle_delivery_failure(data): message_id = data["messageId"] error_code = data["errorCode"] error_message = data["errorMessage"] # Log the failure await db.messages.update(message_id, { "status": "failed", "error_code": error_code, "error_message": error_message, "failed_at": datetime.now() }) # Alert your team for critical failures if error_code in ["30007", "21610"]: await alert_team({ "type": "message_blocked", "message_id": message_id, "reason": error_message }) ``` ```ruby Ruby theme={null} def handle_delivery_failure(data) message_id = data["messageId"] error_code = data["errorCode"] error_message = data["errorMessage"] # Log the failure Message.find(message_id).update!( status: "failed", error_code: error_code, error_message: error_message, failed_at: Time.now ) # Alert your team for critical failures if %w[30007 21610].include?(error_code) alert_team( type: "message_blocked", message_id: message_id, reason: error_message ) end end ``` ```go Go theme={null} func handleDeliveryFailure(data map[string]interface{}) { messageID := data["messageId"].(string) errorCode := data["errorCode"].(string) errorMessage := data["errorMessage"].(string) // Log the failure db.Messages.Update(messageID, map[string]interface{}{ "status": "failed", "errorCode": errorCode, "errorMessage": errorMessage, }) // Alert your team for critical failures if errorCode == "30007" || errorCode == "21610" { alertTeam(map[string]string{ "type": "message_blocked", "messageId": messageID, "reason": errorMessage, }) } } ``` ```php PHP theme={null} update([ 'status' => 'failed', 'error_code' => $errorCode, 'error_message' => $errorMessage, 'failed_at' => now(), ]); // Alert your team for critical failures if (in_array($errorCode, ['30007', '21610'])) { alertTeam([ 'type' => 'message_blocked', 'message_id' => $messageId, 'reason' => $errorMessage, ]); } } ``` *** ## Template Events ## template.status\_changed Triggered when a WhatsApp template's approval status changes. This is useful for tracking when templates are approved, rejected, or disabled by Meta. ```json theme={null} { "id": "evt_1705312200000_tmpl123", "type": "template.status_changed", "timestamp": 1705312200000, "senderId": "snd_abc123", "projectId": "prj_xyz789", "data": { "templateId": "tmpl_xyz789", "templateName": "order_confirmation", "language": "en", "previousStatus": "pending", "currentStatus": "approved", "rejectionReason": null } } ``` ### Data Fields | Field | Type | Description | | - | - | - | | `templateId` | string | Template identifier | | `templateName` | string | Template name | | `language` | string | Template language code | | `previousStatus` | string | Status before the change | | `currentStatus` | string | New status: `approved`, `rejected`, `pending`, `disabled` | | `rejectionReason` | string \| null | Reason for rejection (only present when `currentStatus` is `rejected`) | ### Status Values | Status | Description | | - | - | | `draft` | Template created locally, not yet submitted | | `pending` | Template submitted to Meta, awaiting review | | `approved` | Template approved and ready to use | | `rejected` | Template rejected by Meta | | `disabled` | Template was approved but later disabled by Meta | ### Example Handler ```typescript TypeScript theme={null} async function handleTemplateStatusChange(data) { const { templateId, templateName, currentStatus, rejectionReason } = data; // Update template status in your database await db.templates.update(templateId, { status: currentStatus, rejectionReason, updatedAt: new Date() }); if (currentStatus === "approved") { await notifyTeam({ message: `Template "${templateName}" has been approved!`, type: "success" }); } else if (currentStatus === "rejected") { await notifyTeam({ message: `Template "${templateName}" was rejected: ${rejectionReason}`, type: "error" }); } } ``` ```python Python theme={null} async def handle_template_status_change(data): template_id = data["templateId"] template_name = data["templateName"] current_status = data["currentStatus"] rejection_reason = data.get("rejectionReason") # Update template status in your database await db.templates.update(template_id, { "status": current_status, "rejection_reason": rejection_reason, "updated_at": datetime.now() }) if current_status == "approved": await notify_team({ "message": f'Template "{template_name}" has been approved!', "type": "success" }) elif current_status == "rejected": await notify_team({ "message": f'Template "{template_name}" was rejected: {rejection_reason}', "type": "error" }) ``` ```ruby Ruby theme={null} def handle_template_status_change(data) template_id = data["templateId"] template_name = data["templateName"] current_status = data["currentStatus"] rejection_reason = data["rejectionReason"] # Update template status in your database Template.find(template_id).update!( status: current_status, rejection_reason: rejection_reason, updated_at: Time.now ) if current_status == "approved" notify_team( message: "Template \"#{template_name}\" has been approved!", type: "success" ) elsif current_status == "rejected" notify_team( message: "Template \"#{template_name}\" was rejected: #{rejection_reason}", type: "error" ) end end ``` ```go Go theme={null} func handleTemplateStatusChange(data map[string]interface{}) { templateID := data["templateId"].(string) templateName := data["templateName"].(string) currentStatus := data["currentStatus"].(string) rejectionReason, _ := data["rejectionReason"].(string) // Update template status in your database db.Templates.Update(templateID, map[string]interface{}{ "status": currentStatus, "rejectionReason": rejectionReason, }) if currentStatus == "approved" { notifyTeam(map[string]string{ "message": fmt.Sprintf("Template \"%s\" has been approved!", templateName), "type": "success", }) } else if currentStatus == "rejected" { notifyTeam(map[string]string{ "message": fmt.Sprintf("Template \"%s\" was rejected: %s", templateName, rejectionReason), "type": "error", }) } } ``` ```php PHP theme={null} update([ 'status' => $currentStatus, 'rejection_reason' => $rejectionReason, 'updated_at' => now(), ]); if ($currentStatus === 'approved') { notifyTeam([ 'message' => "Template \"{$templateName}\" has been approved!", 'type' => 'success', ]); } elseif ($currentStatus === 'rejected') { notifyTeam([ 'message' => "Template \"{$templateName}\" was rejected: {$rejectionReason}", 'type' => 'error', ]); } } ``` *** ## Partner Invitation Events ## invitation.status\_changed Triggered when a partner invitation changes status. Use it to follow a client through onboarding without polling. It is delivered to the **project webhook** of the project that created the invitation. If you create invitations with a sub-account's API key, configure the webhook on that sub-account: a parent project does not receive its sub-accounts' events. If that project's default sender has a webhook subscribed to this event, it receives a copy too, with a top-level `senderId`. ```json theme={null} { "id": "evt_1705312200000_inv123", "type": "invitation.status_changed", "timestamp": 1705312200000, "projectId": "prj_xyz789", "data": { "invitationId": "inv_xyz789", "clientName": "Acme Corp", "clientEmail": "contact@acme.com", "connectionType": "whatsapp_waba", "previousStatus": "in_progress", "currentStatus": "completed", "senderId": "snd_newclient123", "wabaAccountId": "waba_abc456", "connectedAccount": { "channel": "whatsapp", "id": "109876543210", "name": "Acme Corp" } } } ``` ### Data Fields | Field | Type | Description | | - | - | - | | `invitationId` | string | Invitation identifier | | `clientName` | string \| null | Client's business name | | `clientEmail` | string \| null | Client's email address | | `connectionType` | string | `whatsapp_waba` or `messenger` | | `previousStatus` | string | Status before the change | | `currentStatus` | string | New status: `in_progress`, `completed`, `failed`, `cancelled`, or `pending` | | `failureReason` | string | Only on `failed`. Stable code such as `fb_cancelled`, `fb_not_authorized` or `signup_abandoned` | | `senderId` | string | Only on `completed`. The sender created for this client | | `wabaAccountId` | string | Only on `completed` WhatsApp invitations. The WhatsApp Business Account ID | | `connectedAccount` | object \| null | Only on `completed`. The account that was linked: `channel`, `id`, `name` | ### When It Fires | Transition | Cause | | - | - | | `pending` or `failed` to `in_progress` | The client opened the link and started the connection | | `in_progress` to `completed` | The connection finished and the sender was created | | `in_progress` to `failed` | The client closed Meta's dialog, denied a permission, or did not finish within an hour | | any open status to `cancelled` | You cancelled the invitation | | `failed` or `cancelled` to `pending` | You resent the invitation from the dashboard, which issues a new link | A change to the same status sends nothing, so retrying a step never produces a duplicate event. > [!NOTE] > > `expired` is never sent. A `pending` invitation past its expiry date reads as `expired` in `GET /v1/invitations`, but nothing changes when that moment passes, so there is no event to deliver. Check `expiresAt` if you need to act on expiry. ### Example Handler ```typescript TypeScript theme={null} async function handleInvitationStatusChanged(data) { const { invitationId, clientName, currentStatus, senderId } = data; if (currentStatus === "completed") { // Client successfully onboarded await notifyTeam({ message: `${clientName} has connected their WhatsApp!`, senderId, }); // Create templates for the new client await createDefaultTemplates(senderId); } else if (currentStatus === "in_progress") { // Client started the process console.log(`${clientName} is completing WhatsApp setup`); } else if (currentStatus === "failed") { // The link still works: the client can retry it console.log(`${clientName} did not finish: ${data.failureReason}`); } } ``` ```python Python theme={null} async def handle_invitation_status_changed(data): invitation_id = data["invitationId"] client_name = data["clientName"] current_status = data["currentStatus"] sender_id = data.get("senderId") if current_status == "completed": # Client successfully onboarded await notify_team({ "message": f"{client_name} has connected their WhatsApp!", "sender_id": sender_id }) # Create templates for the new client await create_default_templates(sender_id) elif current_status == "in_progress": # Client started the process print(f"{client_name} is completing WhatsApp setup") ``` ```ruby Ruby theme={null} def handle_invitation_status_changed(data) invitation_id = data["invitationId"] client_name = data["clientName"] current_status = data["currentStatus"] sender_id = data["senderId"] if current_status == "completed" # Client successfully onboarded notify_team( message: "#{client_name} has connected their WhatsApp!", sender_id: sender_id ) # Create templates for the new client create_default_templates(sender_id) elsif current_status == "in_progress" # Client started the process puts "#{client_name} is completing WhatsApp setup" end end ``` ```go Go theme={null} func handleInvitationStatusChanged(data map[string]interface{}) { clientName := data["clientName"].(string) currentStatus := data["currentStatus"].(string) senderID, _ := data["senderId"].(string) if currentStatus == "completed" { // Client successfully onboarded notifyTeam(map[string]string{ "message": fmt.Sprintf("%s has connected their WhatsApp!", clientName), "senderId": senderID, }) // Create templates for the new client createDefaultTemplates(senderID) } else if currentStatus == "in_progress" { // Client started the process fmt.Printf("%s is completing WhatsApp setup\n", clientName) } } ``` ```php PHP theme={null} "{$clientName} has connected their WhatsApp!", 'sender_id' => $senderId, ]); // Create templates for the new client createDefaultTemplates($senderId); } elseif ($currentStatus === 'in_progress') { // Client started the process echo "{$clientName} is completing WhatsApp setup\n"; } } ``` *** ## Broadcast Events ## broadcast.status\_changed Triggered when a broadcast changes status. This is a **project-level event** — configure it in your project webhook settings. ```json theme={null} { "id": "evt_1705312200000_brd123", "type": "broadcast.status_changed", "timestamp": 1705312200000, "projectId": "prj_xyz789", "data": { "broadcastId": "brd_abc123", "name": "Black Friday Campaign", "status": "approved", "previousStatus": "pending_review", "channel": "sms", "totalContacts": 5000 } } ``` ### Data Fields | Field | Type | Description | | - | - | - | | `broadcastId` | string | Broadcast identifier | | `name` | string | Broadcast campaign name | | `status` | string | New status after the change | | `previousStatus` | string | Status before the change | | `channel` | string | Broadcast channel (`sms`, `whatsapp`, `email`, etc.) | | `totalContacts` | number | Total contacts in the broadcast | ### Status Flow ``` draft → pending_review → approved → sending → completed ↓ rejected → escalated → rejected_final ↑ (edit + retry) ``` | Status | Description | | - | - | | `draft` | Initial state, adding contacts | | `pending_review` | AI content review in progress | | `approved` | Review passed, ready to send | | `rejected` | Content rejected — edit and retry | | `escalated` | Sent to human review | | `rejected_final` | Rejected by human review (cannot appeal) | | `sending` | Messages being delivered | | `completed` | All messages processed | | `cancelled` | Broadcast stopped | ### Example Handler ```typescript TypeScript theme={null} async function handleBroadcastStatusChanged(data) { const { broadcastId, name, status, previousStatus } = data; switch (status) { case "approved": console.log(`Broadcast "${name}" approved — will begin sending`); break; case "rejected": console.log(`Broadcast "${name}" rejected — edit content and retry`); await notifyTeam({ message: `Broadcast rejected: ${name}` }); break; case "completed": console.log(`Broadcast "${name}" completed!`); await generateReport(broadcastId); break; case "cancelled": console.log(`Broadcast "${name}" was cancelled`); break; } } ``` ```python Python theme={null} async def handle_broadcast_status_changed(data): broadcast_id = data["broadcastId"] name = data["name"] status = data["status"] if status == "approved": print(f'Broadcast "{name}" approved — will begin sending') elif status == "rejected": print(f'Broadcast "{name}" rejected — edit content and retry') await notify_team(f"Broadcast rejected: {name}") elif status == "completed": print(f'Broadcast "{name}" completed!') await generate_report(broadcast_id) elif status == "cancelled": print(f'Broadcast "{name}" was cancelled') ``` *** ## Best Practices ### Idempotency Webhook deliveries may be retried, so your handler should be idempotent: ```javascript theme={null} async function handleEvent(event) { // Check if we've already processed this event const existing = await db.processedEvents.find(event.id); if (existing) { console.log(`Event ${event.id} already processed, skipping`); return; } // Process the event await processEventLogic(event); // Mark as processed await db.processedEvents.create({ eventId: event.id }); } ``` ### Error Handling Always wrap your handlers in try-catch to prevent unhandled exceptions: ```javascript theme={null} app.post('/webhooks/zavu', async (req, res) => { try { await handleEvent(req.body); res.status(200).send('OK'); } catch (error) { console.error('Webhook handling error:', error); // Return 200 to prevent retries if the error is not transient res.status(200).send('OK'); } }); ``` ## Next Steps * [Security](/guides/receiving-messages/security) - Verify webhook signatures * [Webhooks](/guides/receiving-messages/webhooks) - Configure your endpoints # Overview Source: https://docs.zavu.dev/guides/receiving-messages/overview Receive inbound messages from your customers via webhooks Zavu delivers incoming messages to your application in real-time via webhooks. When a customer sends you a message via SMS or WhatsApp, we forward it to your configured endpoint within seconds. ## How It Works ``` Customer Message → Zavu → Your Webhook → Your Application ``` 1. A customer sends a message to your Sender's phone number 2. Zavu receives the message from the carrier (SMS) or Meta (WhatsApp) 3. We forward the message to your registered webhook URL 4. Your application processes the message and responds if needed ## Quick Start ### 1. Set Up Your Endpoint Create an endpoint that accepts POST requests. Here's a minimal example: ```typescript TypeScript (Express) theme={null} app.post("/webhooks/zavu", (req, res) => { const event = req.body; if (event.type === "message.inbound") { console.log("New message from:", event.data.from); console.log("Message:", event.data.text); } res.status(200).send("OK"); }); ``` ```python Python (Flask) theme={null} @app.route("/webhooks/zavu", methods=["POST"]) def handle_webhook(): event = request.json if event["type"] == "message.inbound": print(f"New message from: {event['data']['from']}") print(f"Message: {event['data']['text']}") return "OK", 200 ``` ```ruby Ruby (Sinatra) theme={null} post "/webhooks/zavu" do event = JSON.parse(request.body.read) if event["type"] == "message.inbound" puts "New message from: #{event['data']['from']}" puts "Message: #{event['data']['text']}" end status 200 "OK" end ``` ```go Go theme={null} func webhookHandler(w http.ResponseWriter, r *http.Request) { var event map[string]interface{} json.NewDecoder(r.Body).Decode(&event) if event["type"] == "message.inbound" { data := event["data"].(map[string]interface{}) fmt.Println("New message from:", data["from"]) fmt.Println("Message:", data["text"]) } w.WriteHeader(http.StatusOK) w.Write([]byte("OK")) } ``` ```php PHP theme={null} [!NOTE] > > For receiving messages, subscribe to `message.inbound`. Add `conversation.new` if you want to be notified when a new contact messages you for the first time. The other events are for tracking outbound message delivery. > [!TIP] > > Webhooks are configured per Sender. If you have multiple Senders, you can route their events to different endpoints or use the `senderId` field to identify the source. ## Next Steps * [Configure Webhooks](/guides/receiving-messages/webhooks) - Set up your webhook endpoints * [Event Types](/guides/receiving-messages/events) - Detailed event payload documentation * [Security](/guides/receiving-messages/security) - Verify webhook signatures # Security Source: https://docs.zavu.dev/guides/receiving-messages/security Verify webhook signatures to ensure requests are from Zavu Zavu signs every webhook with HMAC-SHA256. Verify the signature before you trust a payload. ## Signature Header Every request carries `X-Zavu-Signature`: ``` X-Zavu-Signature: t=1786113454,v2=b4b2b61cdcdb241e4270298a08c3c13c964642df... ``` | Part | Meaning | | - | - | | `t` | Unix timestamp in **seconds**, when the signature was generated | | `v1` | `HMAC_SHA256(secret, body)` | | `v2` | `HMAC_SHA256(secret, "{t}.{body}")` | A header carries `v1`, `v2`, or both. Which one you get is per receiver, and you control it. ## The two schemes **`v2` is the current scheme**, and it is what new webhooks use. It signs the timestamp together with the body, so more of the request is covered by the signature. **`v1` signs the body only.** Webhooks created before the scheme was configurable are on `v1` and stay there until you move them. There is no deadline. Moving takes three steps and no downtime. See [Migrating to v2 signatures](/guides/receiving-messages/signature-migration). > [!NOTE] > > Older versions of this guide described verifying `v1` by hashing > `{timestamp}.{body}`. That was wrong: `v1` covers the body alone, so a receiver > built that way rejects every delivery. If that is what you have, either fix the > computation or move the sender to `v2`, where hashing `{timestamp}.{body}` is > correct. ## Which scheme is my webhook on? ```sh theme={null} curl https://api.zavu.dev/v1/senders/$SENDER_ID \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` ```json theme={null} { "id": "sndr_...", "webhook": { "url": "https://api.example.com/webhooks/zavu", "events": ["message.inbound"], "active": true, "signatureVersion": "v1" } } ``` ## Verifying Four steps, in this order. ### 1. Read the raw body The signature covers the exact bytes that were sent. A parsed and re-serialized object is not those bytes. ```javascript theme={null} // Express: mount a raw parser on this route, not express.json() app.use('/webhooks/zavu', express.raw({ type: 'application/json' })); ``` ### 2. Parse the header ```javascript theme={null} function parseSignature(header) { const parts = {}; for (const piece of header.split(',')) { const i = piece.indexOf('='); if (i > 0) parts[piece.slice(0, i)] = piece.slice(i + 1); } return parts; // { t, v1?, v2? } } ``` Ignore parts you do not recognize, so a future scheme does not break your parser. ### 3. Check the timestamp ```javascript theme={null} const age = Math.floor(Date.now() / 1000) - Number(parts.t); if (age > 300) return false; // older than five minutes if (age < -60) return false; // clock skew, or forged ``` ### 4. Recompute and compare Use the `t` **from the header**, not your own clock. ```javascript theme={null} const expected = parts.v2 ? crypto.createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex') : crypto.createHmac('sha256', secret).update(rawBody).digest('hex'); const received = parts.v2 ?? parts.v1; ``` Compare in constant time. `===` leaks how many characters matched. ```javascript theme={null} if (expected.length !== received.length) return false; crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received)); ``` ## Complete examples Each one prefers `v2` and falls back to `v1`, so the same code works before, during and after a migration. ```typescript TypeScript (Express) theme={null} import crypto from 'crypto'; import express from 'express'; const app = express(); const SECRET = process.env.ZAVU_WEBHOOK_SECRET!; const MAX_AGE_SECONDS = 300; // Raw body. Not express.json(). app.use('/webhooks/zavu', express.raw({ type: 'application/json' })); function verifyZavuSignature(rawBody: string, header: string, secret: string): boolean { if (!header) return false; const parts: Record = {}; for (const piece of header.split(',')) { const i = piece.indexOf('='); if (i > 0) parts[piece.slice(0, i)] = piece.slice(i + 1); } const timestamp = Number(parts.t); if (!Number.isFinite(timestamp)) return false; const age = Math.floor(Date.now() / 1000) - timestamp; if (age > MAX_AGE_SECONDS || age < -60) return false; // v2 covers the timestamp; v1 covers the body alone. const received = parts.v2 ?? parts.v1; if (!received) return false; const signedPayload = parts.v2 ? `${timestamp}.${rawBody}` : rawBody; const expected = crypto.createHmac('sha256', secret).update(signedPayload).digest('hex'); // Length first: timingSafeEqual throws on a mismatch. if (expected.length !== received.length) return false; return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received)); } app.post('/webhooks/zavu', (req, res) => { const rawBody = req.body.toString('utf8'); const header = req.headers['x-zavu-signature'] as string; if (!verifyZavuSignature(rawBody, header, SECRET)) { return res.status(401).send('Invalid signature'); } // Answer fast, then work. A slow handler turns one event into five retries. res.status(200).send('OK'); processEvent(JSON.parse(rawBody)).catch(console.error); }); ``` ```python Python (Flask) theme={null} import hashlib import hmac import time from flask import Flask, request app = Flask(__name__) SECRET = os.environ["ZAVU_WEBHOOK_SECRET"] MAX_AGE_SECONDS = 300 def verify_zavu_signature(raw_body: bytes, header: str, secret: str) -> bool: if not header: return False parts = {} for piece in header.split(","): key, sep, value = piece.partition("=") if sep: parts[key] = value try: timestamp = int(parts["t"]) except (KeyError, ValueError): return False age = int(time.time()) - timestamp if age > MAX_AGE_SECONDS or age < -60: return False # v2 covers the timestamp; v1 covers the body alone. received = parts.get("v2") or parts.get("v1") if not received: return False signed = f"{timestamp}.".encode() + raw_body if "v2" in parts else raw_body expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, received) @app.post("/webhooks/zavu") def zavu_webhook(): raw_body = request.get_data() # bytes, before any parsing header = request.headers.get("X-Zavu-Signature", "") if not verify_zavu_signature(raw_body, header, SECRET): return "Invalid signature", 401 event = request.get_json() # Queue the work; answer now. enqueue(event) return "OK", 200 ``` ```ruby Ruby (Sinatra) theme={null} require 'openssl' require 'sinatra' SECRET = ENV['ZAVU_WEBHOOK_SECRET'] MAX_AGE_SECONDS = 300 def verify_zavu_signature(raw_body, header, secret) return false if header.nil? || header.empty? parts = {} header.split(',').each do |piece| key, _, value = piece.partition('=') parts[key] = value unless value.empty? end timestamp = Integer(parts['t'], exception: false) return false if timestamp.nil? age = Time.now.to_i - timestamp return false if age > MAX_AGE_SECONDS || age < -60 # v2 covers the timestamp; v1 covers the body alone. received = parts['v2'] || parts['v1'] return false if received.nil? signed = parts['v2'] ? "#{timestamp}.#{raw_body}" : raw_body expected = OpenSSL::HMAC.hexdigest('SHA256', secret, signed) OpenSSL.secure_compare(expected, received) end post '/webhooks/zavu' do raw_body = request.body.read header = request.env['HTTP_X_ZAVU_SIGNATURE'] halt 401, 'Invalid signature' unless verify_zavu_signature(raw_body, header, SECRET) event = JSON.parse(raw_body) enqueue(event) status 200 'OK' end ``` ```go Go theme={null} package main import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "io" "net/http" "os" "strconv" "strings" "time" ) const maxAgeSeconds = 300 func verifyZavuSignature(rawBody []byte, header, secret string) bool { if header == "" { return false } parts := map[string]string{} for _, piece := range strings.Split(header, ",") { if k, v, ok := strings.Cut(piece, "="); ok { parts[k] = v } } timestamp, err := strconv.ParseInt(parts["t"], 10, 64) if err != nil { return false } age := time.Now().Unix() - timestamp if age > maxAgeSeconds || age < -60 { return false } // v2 covers the timestamp; v1 covers the body alone. received, hasV2 := parts["v2"] if !hasV2 { received = parts["v1"] } if received == "" { return false } mac := hmac.New(sha256.New, []byte(secret)) if hasV2 { mac.Write([]byte(strconv.FormatInt(timestamp, 10) + ".")) } mac.Write(rawBody) expected := hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(expected), []byte(received)) } func zavuWebhook(w http.ResponseWriter, r *http.Request) { rawBody, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } if !verifyZavuSignature(rawBody, r.Header.Get("X-Zavu-Signature"), os.Getenv("ZAVU_WEBHOOK_SECRET")) { http.Error(w, "Invalid signature", http.StatusUnauthorized) return } w.WriteHeader(http.StatusOK) w.Write([]byte("OK")) go processEvent(rawBody) } ``` ```php PHP theme={null} MAX_AGE_SECONDS || $age < -60) return false; // v2 covers the timestamp; v1 covers the body alone. $received = $parts['v2'] ?? $parts['v1'] ?? null; if ($received === null) return false; $signed = isset($parts['v2']) ? "{$timestamp}.{$rawBody}" : $rawBody; $expected = hash_hmac('sha256', $signed, $secret); return hash_equals($expected, $received); } $rawBody = file_get_contents('php://input'); $header = $_SERVER['HTTP_X_ZAVU_SIGNATURE'] ?? null; if (!verifyZavuSignature($rawBody, $header, getenv('ZAVU_WEBHOOK_SECRET'))) { http_response_code(401); exit('Invalid signature'); } http_response_code(200); echo 'OK'; ``` ## Moving a webhook to v2 Three steps, and the middle one is what makes this safe. **1. Turn on both signatures.** ```sh theme={null} curl -X PATCH https://api.zavu.dev/v1/senders/$SENDER_ID \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{"webhookSignatureVersion": "v1+v2"}' ``` Both signatures arrive in the same header, sharing one `t`. Your current receiver still verifies `v1` and notices nothing. **2. Deploy a receiver that verifies `v2`, and watch real deliveries.** The examples above already do this: they prefer `v2` when it is present. Confirm in your own logs that deliveries are landing before moving on. **3. Turn `v1` off.** ```sh theme={null} curl -X PATCH https://api.zavu.dev/v1/senders/$SENDER_ID \ -d '{"webhookSignatureVersion": "v2"}' ``` > [!WARNING] > > Going from `v1` straight to `v2` is rejected with `400`. Step 2 is where you > confirm your receiver works, and it is worth doing properly: a receiver that > answers `200` before it verifies looks identical to a working one from our > side, so a passing test request proves nothing. Watch your own logs. ## Check the timestamp, and be idempotent Two separate things, and you want both. Reject deliveries whose `t` is far from now, as the examples above do. And handle repeats: **legitimate** retries are real deliveries with a fresh timestamp and a valid signature. Zavu retries non-2xx responses with backoff, and delivery is at-least-once, so you will see the same event twice. Store `event.id` and ignore what you have already processed. ## Troubleshooting ### Every delivery returns 401 In order of likelihood: 1. **You are hashing the wrong payload.** Check `signatureVersion` on the sender. On `v1` hash the body alone; on `v2` hash `{t}.{body}`. 2. **A body parser ran first.** The signature covers the raw bytes. `JSON.stringify(JSON.parse(x))` is not always `x`. 3. **Wrong secret.** It is per sender. Regenerating it invalidates the old one immediately, with no overlap. 4. **Milliseconds.** `t` is in seconds. ### It worked, then stopped Check whether the sender's `signatureVersion` changed, and whether the secret was regenerated. Both take effect on the next delivery. ### Testing locally Send yourself a signed request rather than disabling verification: ```sh theme={null} BODY='{"id":"evt_test","type":"message.inbound","data":{}}' SIG=$(node -e " const c=require('crypto'); const t=Math.floor(Date.now()/1000); const b=process.argv[1]; console.log('t='+t+',v2='+c.createHmac('sha256','$ZAVU_WEBHOOK_SECRET').update(t+'.'+b).digest('hex')); " "$BODY") curl -i -X POST localhost:3000/webhooks/zavu \ -H "X-Zavu-Signature: $SIG" \ -H "Content-Type: application/json" \ -d "$BODY" ``` Expect `200`. Then change one character of the body and expect `401`. A receiver that returns `200` to both is not verifying anything. ## Next Steps * [Event Types](/guides/receiving-messages/events) - Understand webhook payloads * [Webhooks](/guides/receiving-messages/webhooks) - Configure your endpoints # Migrating to v2 signatures Source: https://docs.zavu.dev/guides/receiving-messages/signature-migration Move a webhook to the current signature scheme without downtime Zavu signs webhooks with HMAC-SHA256. There are two schemes, and each receiver is on one of them: | Scheme | What is signed | | - | - | | `v1` | the request body | | `v2` | the timestamp and the request body, joined as `{t}.{body}` | `v2` is the current scheme and covers more of the request. Webhooks created from now on use it. Anything created earlier stays on `v1` until you move it, and there is no deadline. ## Check where you are ```sh theme={null} curl https://api.zavu.dev/v1/senders/$SENDER_ID \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` ```json theme={null} { "webhook": { "url": "https://api.example.com/webhooks/zavu", "active": true, "signatureVersion": "v1" } } ``` In the dashboard, every sender webhook and its scheme is listed under **Webhooks**. ## The three steps Moving is deliberately not one switch. The middle step is where both signatures arrive at once, so you can deploy and confirm your new code before anything depends on it. ### 1. Turn on both ```sh theme={null} curl -X PATCH https://api.zavu.dev/v1/senders/$SENDER_ID \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{"webhookSignatureVersion": "v1+v2"}' ``` The header now carries both, sharing one timestamp: ``` X-Zavu-Signature: t=1786113454,v1=52a50333…,v2=b4b2b61c… ``` Your current receiver reads `v1` and is unaffected. Nothing about your deliveries changes. ### 2. Verify v2, and confirm it in your own logs Prefer `v2` when it is present, fall back to `v1`. The same code then works before, during, and after the move: ```javascript theme={null} const parts = {}; for (const piece of header.split(",")) { const i = piece.indexOf("="); if (i > 0) parts[piece.slice(0, i)] = piece.slice(i + 1); } const received = parts.v2 ?? parts.v1; const signedPayload = parts.v2 ? `${parts.t}.${rawBody}` : rawBody; const expected = crypto .createHmac("sha256", secret) .update(signedPayload) .digest("hex"); ``` Full examples in five languages are in [Security](/guides/receiving-messages/security). Deploy it, then wait for real deliveries and confirm they are being accepted. **This is the step that matters.** Do not skip to step 3 on the strength of a test request: a receiver that returns `200` before it verifies looks identical to a working one from our side. ### 3. Turn v1 off ```sh theme={null} curl -X PATCH https://api.zavu.dev/v1/senders/$SENDER_ID \ -d '{"webhookSignatureVersion": "v2"}' ``` From the next delivery the header carries `v2` only. > [!NOTE] > > Going from `v1` straight to `v2` returns `400`. Set `v1+v2` first. The overlap > costs nothing and can last as long as you want. ## Rolling back Set `v1+v2` again. Both signatures return immediately, so a receiver that reads either one keeps working while you sort things out. ```sh theme={null} curl -X PATCH https://api.zavu.dev/v1/senders/$SENDER_ID \ -d '{"webhookSignatureVersion": "v1+v2"}' ``` ## Several senders, one endpoint If one endpoint serves more than one sender, move them one at a time and keep the tolerant verifier from step 2. It accepts both schemes, so senders on different settings can share a receiver for as long as you need. When you create a new sender that points at an endpoint still verifying `v1`, create it on `v1+v2` explicitly: ```sh theme={null} curl -X POST https://api.zavu.dev/v1/senders \ -d '{ "name": "Support", "enableSmsOneway": true, "webhookUrl": "https://api.example.com/webhooks/zavu", "webhookSignatureVersion": "v1+v2" }' ``` Otherwise it defaults to `v2` and that endpoint will reject its deliveries. ## Next Steps * [Security](/guides/receiving-messages/security) - Full verification examples * [Webhooks](/guides/receiving-messages/webhooks) - Configure your endpoints # Webhooks Source: https://docs.zavu.dev/guides/receiving-messages/webhooks Configure webhooks to receive inbound messages and delivery updates Webhooks allow your application to receive real-time notifications when events occur, such as incoming messages, delivery status changes, or partner invitation updates. Zavu supports two types of webhooks: * **Sender Webhooks**: Receive events for a specific sender (messages, conversations) * **Project Webhooks**: Receive project-level events (partner invitations) ## Project Webhooks Project webhooks are ideal for receiving notifications about project-level events, such as when a partner invitation is completed. This is particularly useful if you're using the Partner Invitations API to onboard clients. A project webhook receives events for its own project only. If you onboard each client in a sub-account, configure the webhook with that sub-account's API key: the parent project's webhook does not receive events from its sub-accounts. ### Via Dashboard 1. Go to [Dashboard](https://dashboard.zavu.dev) and select your project 2. Navigate to **Webhooks** in the sidebar 3. In the "Project Webhook" section, click **Configure** 4. Enter your webhook URL and select the events you want to receive 5. Save - you'll receive a signing secret ### Via API Configure a project webhook using the invitations API: ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/invitations/webhook \ -H "Authorization: Bearer zv_live_xxx" \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-app.com/webhooks/zavu", "events": ["invitation.status_changed"] }' ``` Response: ```json theme={null} { "webhook": { "url": "https://your-app.com/webhooks/zavu", "events": ["invitation.status_changed"], "secret": "whsec_abc123def456...", "active": true } } ``` ### Managing Project Webhooks **Get current configuration:** ```bash cURL theme={null} curl https://api.zavu.dev/v1/invitations/webhook \ -H "Authorization: Bearer zv_live_xxx" ``` **Update webhook:** only works on a webhook created with `POST` first. Updating one that does not exist returns `400`. ```bash cURL theme={null} curl -X PATCH https://api.zavu.dev/v1/invitations/webhook \ -H "Authorization: Bearer zv_live_xxx" \ -H "Content-Type: application/json" \ -d '{ "url": "https://new-url.com/webhooks", "active": true }' ``` **Remove webhook:** ```bash cURL theme={null} curl -X DELETE https://api.zavu.dev/v1/invitations/webhook \ -H "Authorization: Bearer zv_live_xxx" ``` ### Project Webhook Events | Event | Description | | - | - | | `invitation.status_changed` | A partner invitation status changed (in\_progress, completed, failed, cancelled, or back to pending on resend). See [the event reference](/guides/receiving-messages/events) | ### Project Webhook Payload ```json theme={null} { "id": "evt_1234567890_abc123", "type": "invitation.status_changed", "timestamp": 1705312200000, "projectId": "prj_xyz789", "data": { "invitationId": "inv_abc123", "clientName": "John Doe", "clientEmail": "john@example.com", "connectionType": "whatsapp_waba", "previousStatus": "in_progress", "currentStatus": "completed", "senderId": "snd_new123", "wabaAccountId": "waba_123456", "connectedAccount": { "channel": "whatsapp", "id": "109876543210", "name": "John Doe Store" } } } ``` > [!NOTE] > > Project webhooks are independent of sender webhooks. You can have both configured simultaneously. *** ## Sender Webhooks Each sender can have one webhook configured. Webhooks are managed as part of the sender resource. ### Via Dashboard 1. Go to [Dashboard](https://dashboard.zavu.dev) and select your project 2. Navigate to your Sender's settings 3. Click **Add Webhook** 4. Enter your webhook URL and select the events you want to receive 5. Save - you'll receive a signing secret ### Via API Configure a webhook when creating a new sender: ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/senders \ -H "Authorization: Bearer zv_live_xxx" \ -H "Content-Type: application/json" \ -d '{ "name": "My Sender", "phoneNumber": "+15551234567", "webhookUrl": "https://your-app.com/webhooks/zavu", "webhookEvents": ["message.inbound", "message.delivered", "message.failed"] }' ``` Response: ```json theme={null} { "id": "snd_abc123", "name": "My Sender", "phoneNumber": "+15551234567", "isDefault": false, "webhook": { "url": "https://your-app.com/webhooks/zavu", "events": ["message.inbound", "message.delivered", "message.failed"], "secret": "whsec_abc123def456...", "active": true }, "createdAt": "2024-01-15T10:30:00.000Z" } ``` > [!WARNING] > > Store your webhook secret securely. It's only shown once when you create the sender with a webhook. You'll need it to verify webhook signatures. ## Available Events ### Inbound (Receiving Messages) | Event | Description | | - | - | | `conversation.new` | First message from a new contact (new conversation started) | | `message.inbound` | A customer sent you a message | ### Outbound (Delivery Tracking) | Event | Description | | - | - | | `message.queued` | Your message was queued for delivery | | `message.sent` | Your message was sent to the carrier | | `message.delivered` | Your message was delivered to the recipient | | `message.read` | Your message was read by the recipient (WhatsApp only) | | `message.failed` | Message delivery failed | ### Templates | Event | Description | | - | - | | `template.status_changed` | A WhatsApp template status changed (draft, pending, approved, rejected) | ### Partner Invitations (Project Webhook) | Event | Description | | - | - | | `invitation.status_changed` | A partner invitation status changed (in\_progress, completed, failed, cancelled, or back to pending on resend). See [the event reference](/guides/receiving-messages/events) | > [!NOTE] > > The `invitation.status_changed` event is delivered via **Project Webhooks**. See the [Project Webhooks](#project-webhooks) section above for configuration. A sender webhook receives it only when that sender is the project's default sender and subscribes to the event. > [!TIP] > > Subscribe to `message.inbound` to receive all customer messages. Add `conversation.new` to get notified when a new contact messages you for the first time (useful for lead tracking). The outbound events are optional and used for tracking delivery status. ## Webhook Payload All webhook payloads follow the same structure: ```json theme={null} { "id": "evt_1234567890_abc123", "type": "message.inbound", "timestamp": 1705312200000, "senderId": "snd_abc123", "projectId": "prj_xyz789", "data": { // Event-specific data } } ``` | Field | Description | | - | - | | `id` | Unique event identifier | | `type` | Event type (e.g., `message.inbound`) | | `timestamp` | Unix timestamp (ms) when **Zavu dispatched the webhook** — not when the message was sent. For inbound messages, compare against `data.providerTimestamp` (the channel's original receive time) to detect delayed deliveries. | | `senderId` | The Sender that received this event | | `projectId` | Your project ID | | `data` | Event-specific payload | ## Handling Webhooks Your webhook endpoint must: 1. **Respond with 2xx status** within 30 seconds 2. **Verify the signature** to ensure the request is from Zavu 3. **Process asynchronously** for long-running tasks ```typescript TypeScript (Express) theme={null} import express from "express"; const app = express(); app.post("/webhooks/zavu", express.json(), (req, res) => { // 1. Verify signature (see Security guide) const signature = req.headers["x-zavu-signature"]; if (!verifySignature(signature, req.body, process.env.WEBHOOK_SECRET)) { return res.status(401).send("Invalid signature"); } // 2. Acknowledge receipt immediately res.status(200).send("OK"); // 3. Process the event asynchronously const event = req.body; processEvent(event).catch(console.error); }); async function processEvent(event) { switch (event.type) { case "message.inbound": await handleInboundMessage(event.data); break; case "message.delivered": await handleDeliveryConfirmation(event.data); break; case "message.failed": await handleDeliveryFailure(event.data); break; } } ``` ```python Python (Flask) theme={null} from flask import Flask, request import threading app = Flask(__name__) @app.route("/webhooks/zavu", methods=["POST"]) def handle_webhook(): # 1. Verify signature (see Security guide) signature = request.headers.get("X-Zavu-Signature") if not verify_signature(signature, request.data, WEBHOOK_SECRET): return "Invalid signature", 401 # 2. Acknowledge receipt immediately event = request.json # 3. Process the event asynchronously thread = threading.Thread(target=process_event, args=(event,)) thread.start() return "OK", 200 def process_event(event): if event["type"] == "message.inbound": handle_inbound_message(event["data"]) elif event["type"] == "message.delivered": handle_delivery_confirmation(event["data"]) elif event["type"] == "message.failed": handle_delivery_failure(event["data"]) ``` ```ruby Ruby (Sinatra) theme={null} require "sinatra" require "json" post "/webhooks/zavu" do # 1. Verify signature (see Security guide) signature = request.env["HTTP_X_ZAVU_SIGNATURE"] raw_body = request.body.read unless verify_signature(signature, raw_body, ENV["WEBHOOK_SECRET"]) halt 401, "Invalid signature" end # 2. Acknowledge receipt immediately event = JSON.parse(raw_body) # 3. Process the event asynchronously Thread.new { process_event(event) } status 200 "OK" end def process_event(event) case event["type"] when "message.inbound" handle_inbound_message(event["data"]) when "message.delivered" handle_delivery_confirmation(event["data"]) when "message.failed" handle_delivery_failure(event["data"]) end end ``` ```go Go theme={null} package main import ( "encoding/json" "io" "net/http" ) func webhookHandler(w http.ResponseWriter, r *http.Request) { // 1. Verify signature (see Security guide) signature := r.Header.Get("X-Zavu-Signature") body, _ := io.ReadAll(r.Body) if !verifySignature(signature, body, webhookSecret) { http.Error(w, "Invalid signature", http.StatusUnauthorized) return } // 2. Acknowledge receipt immediately w.WriteHeader(http.StatusOK) w.Write([]byte("OK")) // 3. Process the event asynchronously var event map[string]interface{} json.Unmarshal(body, &event) go processEvent(event) } func processEvent(event map[string]interface{}) { switch event["type"] { case "message.inbound": handleInboundMessage(event["data"]) case "message.delivered": handleDeliveryConfirmation(event["data"]) case "message.failed": handleDeliveryFailure(event["data"]) } } ``` ```php PHP theme={null} [!TIP] > > Use a service like [webhook.site](https://webhook.site) or [ngrok](https://ngrok.com) for testing webhooks during development. ## Managing Webhooks ### Update Webhook Configuration Update your sender's webhook settings: ```bash cURL theme={null} curl -X PATCH https://api.zavu.dev/v1/senders/snd_abc123 \ -H "Authorization: Bearer zv_live_xxx" \ -H "Content-Type: application/json" \ -d '{ "webhookUrl": "https://new-url.com/webhooks", "webhookEvents": ["message.inbound"], "webhookActive": true }' ``` ### Disable Webhook ```bash cURL theme={null} curl -X PATCH https://api.zavu.dev/v1/senders/snd_abc123 \ -H "Authorization: Bearer zv_live_xxx" \ -H "Content-Type: application/json" \ -d '{ "webhookActive": false }' ``` ### Remove Webhook Set `webhookUrl` to `null` to remove the webhook: ```bash cURL theme={null} curl -X PATCH https://api.zavu.dev/v1/senders/snd_abc123 \ -H "Authorization: Bearer zv_live_xxx" \ -H "Content-Type: application/json" \ -d '{ "webhookUrl": null }' ``` ### Regenerate Secret If your webhook secret is compromised: ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/senders/snd_abc123/webhook/secret \ -H "Authorization: Bearer zv_live_xxx" ``` Response: ```json theme={null} { "secret": "whsec_new_secret_here..." } ``` ## Next Steps * [Event Types](/guides/receiving-messages/events) - Detailed payload for each event * [Security](/guides/receiving-messages/security) - Verify webhook signatures # Adding Channels to a Sender Source: https://docs.zavu.dev/guides/senders/adding-channels Step-by-step guide to configure SMS, WhatsApp, Email, Telegram, Instagram, Messenger, and Voice channels on your sender Channels are the communication pathways your sender uses to deliver messages. Each channel has different capabilities, requirements, and use cases. This guide walks you through adding and configuring each channel. ## Overview A sender can have multiple channels configured, enabling features like smart routing and automatic fallback. Here's a quick reference: | Channel | Requirements | Setup Time | Smart Routing | Fallback | | - | - | - | - | - | | SMS | Phone number with SMS capability | Instant | Yes | Yes | | WhatsApp | WABA connection + phone number | 5-10 min | Yes | Yes | | Email | Verified domain (DKIM records) | 10-30 min | No | No | | Telegram | Bot token from BotFather | 5 min | No | No | | Instagram | Meta App + Business Account | 10-15 min | No | No | | Messenger | Facebook Page | 5 min | No | No | | Voice | Phone number with voice capability | Instant | No | No | > [!NOTE] > > SMS and WhatsApp participate in smart routing and automatic fallback. Email, Telegram, Instagram, Messenger, and Voice are standalone channels that must be explicitly selected. ## Prerequisites Before adding channels: 1. **API key or dashboard access** - You need access to your [Zavu Dashboard](https://dashboard.zavu.dev) 2. **A sender profile** - Create a sender first if you haven't already 3. **Channel-specific credentials** - See each channel section below ## Adding Channels via Dashboard **1. Navigate to Sender Settings** Go to **Senders** in your [Zavu Dashboard](https://dashboard.zavu.dev) and select the sender you want to configure. **2. Open the Channels Tab** Click the **Channels** tab to view all available messaging channels and their current status. **3. Add a Channel** Click the **Add** button next to the channel you want to enable. Each channel has a specific setup flow. **4. Complete Configuration** Follow the channel-specific setup wizard to complete the configuration. *** ## SMS Channel SMS is the most straightforward channel to configure. It uses your sender's phone number to send text messages. ### Requirements * Phone number with SMS capability (purchased through Zavu or your own) * Phone number must be assigned to the sender ### Setup via Dashboard 1. Navigate to your sender's **Channels** tab 2. The SMS channel is automatically enabled when you assign a phone number with SMS capability to your sender 3. Verify the status shows **Active** ### Setup via API When creating or updating a sender with a phone number that has SMS capability, SMS is automatically enabled: ```typescript TypeScript theme={null} import Zavu from "@zavudev/sdk"; const zavu = new Zavu({ apiKey: process.env.ZAVUDEV_API_KEY }); const sender = await zavu.senders.create({ name: "Support Line", phoneNumber: "+15551234567", // Phone with SMS capability setAsDefault: true, }); console.log("Sender created:", sender.id); ``` ```python Python theme={null} from zavudev import Zavudev zavu = Zavudev(api_key=os.environ["ZAVUDEV_API_KEY"]) sender = zavu.senders.create( name="Support Line", phone_number="+15551234567", # Phone with SMS capability set_as_default=True, ) print(f"Sender created: {sender.id}") ``` ```ruby Ruby theme={null} require "zavudev" client = Zavudev::Client.new(api_key: ENV["ZAVUDEV_API_KEY"]) sender = client.senders.create( name: "Support Line", phone_number: "+15551234567", # Phone with SMS capability set_as_default: true ) puts "Sender created: #{sender.id}" ``` ```go Go theme={null} sender, _ := client.Senders.New(context.TODO(), zavudev.SenderCreateParams{ Name: zavudev.String("Support Line"), PhoneNumber: zavudev.String("+15551234567"), SetAsDefault: zavudev.Bool(true), }) fmt.Println("Sender created:", sender.ID) ``` ```php PHP theme={null} senders->create([ 'name' => 'Support Line', 'phoneNumber' => '+15551234567', 'setAsDefault' => true, ]); echo "Sender created: " . $sender->id . "\n"; ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/senders \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Support Line", "phoneNumber": "+15551234567", "setAsDefault": true }' ``` ### Verifying SMS Status Check if SMS is properly configured: ```typescript TypeScript theme={null} const sender = await zavu.senders.retrieve({ senderId: "sender_12345" }); console.log("Phone number:", sender.phoneNumber); // SMS is active if the phone number has SMS capability ``` ```python Python theme={null} sender = zavu.senders.retrieve(sender_id="sender_12345") print(f"Phone number: {sender.phone_number}") ``` ```ruby Ruby theme={null} sender = client.senders.retrieve(sender_id: "sender_12345") puts "Phone number: #{sender.phone_number}" ``` ```go Go theme={null} sender, _ := client.Senders.Get(context.TODO(), "sender_12345") fmt.Println("Phone number:", sender.PhoneNumber) ``` ```php PHP theme={null} senders->retrieve('sender_12345'); echo "Phone number: " . $sender->phoneNumber . "\n"; ``` ```bash cURL theme={null} curl https://api.zavu.dev/v1/senders/sender_12345 \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` *** ## WhatsApp Channel WhatsApp requires connecting a WhatsApp Business Account (WABA) through Meta's Business Platform. ### Requirements * Phone number (Zavu number recommended, or your own) * Meta Business account * WhatsApp Business Account (created during setup) ### Connection Options > [card] Zavu Phone Number > > **Recommended.** Use a phone number from your Zavu account. Best for dedicated business lines. > [card] Own Phone Number > > Register any phone number you own via SMS/call verification. ### Setup via Dashboard **1. Open WhatsApp Setup** In your sender's **Channels** tab, click **Add** next to WhatsApp. **2. Choose Connection Method** Select whether to use a Zavu phone number (recommended) or your own phone number. **3. Complete Meta Business Signup** Follow the Meta embedded signup wizard to create or connect your WhatsApp Business Account. **4. Verify Phone Number** Enter your phone number and verify it. For Zavu numbers, select **Call** verification (not SMS). > [!WARNING] > > Zavu phone numbers receive verification codes via voice call. Always select **Call** verification for Zavu numbers. **5. Enter Verification Code** Enter the verification code displayed in the dashboard to complete setup. ### After WhatsApp Connection Once connected, your sender response will include WhatsApp details: ```json theme={null} { "id": "sender_12345", "name": "Support", "phoneNumber": "+15551234567", "whatsapp": { "phoneNumberId": "123456789012345", "displayPhoneNumber": "+15551234567", "paymentStatus": { "setupStatus": "COMPLETE", "methodStatus": "VALID", "canSendTemplates": true } } } ``` > [!TIP] > > After connecting WhatsApp, configure your [WhatsApp Business Profile](/guides/whatsapp/profile) and create [message templates](/guides/whatsapp/templates/overview) for outbound conversations. *** ## Email Channel Email requires domain verification to ensure deliverability and prevent spam filtering. ### Requirements * A domain you control (e.g., `yourcompany.com`) * Access to DNS settings for the domain * Verification typically takes 10-30 minutes ### Setup via Dashboard **1. Navigate to Email Domains** Go to **Email Domains** in your dashboard and click **Add Domain**. **2. Enter Your Domain** Enter your domain (e.g., `yourcompany.com`). **3. Add DNS Records** Add the DKIM records provided to your domain's DNS: ``` selector1._domainkey.yourcompany.com CNAME selector1.dkim.zavu.dev selector2._domainkey.yourcompany.com CNAME selector2.dkim.zavu.dev selector3._domainkey.yourcompany.com CNAME selector3.dkim.zavu.dev ``` **4. Wait for Verification** Zavu checks DNS records every 5 minutes. Status will change to **Verified** once complete. **5. Assign to Sender** In your sender's **Channels** tab, enable email and select your verified domain. > [!TIP] > > DNS propagation typically takes a few minutes but can take up to 48 hours in some cases. ### Email Configuration Configure your email sender identity: | Setting | Example | Description | | - | - | - | | From Address | `noreply@yourcompany.com` | The address emails are sent from | | Reply-To | `support@yourcompany.com` | Where replies are directed | ### Receiving Inbound Email To receive inbound emails, enable email receiving on your sender: ```typescript TypeScript theme={null} await zavu.senders.update({ senderId: "sender_12345", emailReceivingEnabled: true, }); ``` ```ruby Ruby theme={null} client.senders.update( sender_id: "sender_12345", email_receiving_enabled: true ) ``` ```go Go theme={null} client.Senders.Update(context.TODO(), "sender_12345", zavudev.SenderUpdateParams{ EmailReceivingEnabled: zavudev.Bool(true), }) ``` ```php PHP theme={null} senders->update('sender_12345', [ 'emailReceivingEnabled' => true, ]); ``` ```bash cURL theme={null} curl -X PATCH https://api.zavu.dev/v1/senders/sender_12345 \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{"emailReceivingEnabled": true}' ``` *** ## Telegram Channel Telegram requires creating a bot through BotFather and connecting it to your sender. ### Requirements * Telegram account * Bot token from [@BotFather](https://t.me/BotFather) ### Create a Telegram Bot **1. Open BotFather** Open Telegram and search for [@BotFather](https://t.me/BotFather). **2. Create New Bot** Send `/newbot` and follow the prompts to name your bot. **3. Save Bot Token** BotFather provides a token like: ``` 123456789:ABCdefGHIjklMNOpqrsTUVwxyz ``` > [!WARNING] > > Keep your bot token secret. Anyone with this token can control your bot. ### Setup via Dashboard 1. Go to your sender's **Channels** tab 2. Click **Add** next to Telegram 3. Paste your bot token 4. Click **Verify** to confirm the bot is valid 5. Click **Save** to complete setup Zavu automatically registers the webhook with Telegram when you save. ### Setup via API ```typescript TypeScript theme={null} // Telegram configuration is done via the dashboard // The sender will include telegram details after setup: const sender = await zavu.senders.retrieve({ senderId: "sender_12345" }); // Send a Telegram message (requires user to message bot first) await zavu.messages.send({ to: "123456789", // Chat ID from inbound message text: "Thanks for reaching out!", channel: "telegram", }); ``` ```python Python theme={null} # Telegram configuration is done via the dashboard sender = zavu.senders.retrieve(sender_id="sender_12345") # Send a Telegram message (requires user to message bot first) zavu.messages.send( to="123456789", # Chat ID from inbound message text="Thanks for reaching out!", channel="telegram" ) ``` ```ruby Ruby theme={null} # Telegram configuration is done via the dashboard sender = client.senders.retrieve(sender_id: "sender_12345") # Send a Telegram message (requires user to message bot first) client.messages.send_( to: "123456789", # Chat ID from inbound message text: "Thanks for reaching out!", channel: "telegram" ) ``` ```go Go theme={null} // Telegram configuration is done via the dashboard sender, _ := client.Senders.Get(context.TODO(), "sender_12345") // Send a Telegram message (requires user to message bot first) client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("123456789"), Text: zavudev.String("Thanks for reaching out!"), Channel: zavudev.String("telegram"), }) ``` ```php PHP theme={null} senders->retrieve('sender_12345'); // Send a Telegram message (requires user to message bot first) $client->messages->send([ 'to' => '123456789', 'text' => 'Thanks for reaching out!', 'channel' => 'telegram', ]); ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "123456789", "text": "Thanks for reaching out!", "channel": "telegram" }' ``` > [!NOTE] > > Telegram users must initiate contact with your bot first. You cannot message users who haven't started a conversation. *** ## Instagram Channel Instagram messaging requires connecting through Meta's Business Platform with your Instagram Business Account. ### Requirements * Instagram Business Account (not personal) * Meta Business account * Meta App with Instagram Messaging permissions ### Setup via Dashboard **1. Open Instagram Setup** In your sender's **Channels** tab, click **Add** next to Instagram. **2. Connect Meta App** Follow the wizard to connect your Meta App and Instagram Business Account. **3. Grant Permissions** Authorize the required Instagram messaging permissions. **4. Select Instagram Account** Choose which Instagram Business Account to connect. ### Sending Instagram Messages ```typescript TypeScript theme={null} await zavu.messages.send({ to: "17841400000000000", // Instagram-scoped User ID text: "Thanks for your message!", channel: "instagram", }); ``` ```python Python theme={null} zavu.messages.send( to="17841400000000000", # Instagram-scoped User ID text="Thanks for your message!", channel="instagram" ) ``` ```ruby Ruby theme={null} client.messages.send_( to: "17841400000000000", # Instagram-scoped User ID text: "Thanks for your message!", channel: "instagram" ) ``` ```go Go theme={null} client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("17841400000000000"), Text: zavudev.String("Thanks for your message!"), Channel: zavudev.String("instagram"), }) ``` ```php PHP theme={null} messages->send([ 'to' => '17841400000000000', 'text' => 'Thanks for your message!', 'channel' => 'instagram', ]); ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "17841400000000000", "text": "Thanks for your message!", "channel": "instagram" }' ``` > [!NOTE] > > Instagram has a 24-hour messaging window similar to WhatsApp. You can only message users who have contacted your Instagram account within the last 24 hours. *** ## Messenger Channel Messenger lets your sender receive and reply to Facebook Page conversations — including **Facebook Marketplace chats** — by connecting a Facebook Page. ### Requirements * A Facebook Page (admin access) * Meta account login during setup ### Setup via Dashboard **1. Open Messenger Setup** In your sender's **Channels** tab, click **Add** next to Messenger. **2. Connect with Meta** Log in with the Facebook account that manages your Page and grant the Page messaging permissions. **3. Select Facebook Page** Choose which Facebook Page to connect. Webhooks are subscribed automatically. ### Sending Messenger Messages ```typescript TypeScript theme={null} await zavu.messages.send({ to: "24025631120151183", // Messenger user ID (PSID) text: "Thanks for your message!", channel: "messenger", }); ``` ```python Python theme={null} zavu.messages.send( to="24025631120151183", # Messenger user ID (PSID) text="Thanks for your message!", channel="messenger" ) ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "24025631120151183", "text": "Thanks for your message!", "channel": "messenger" }' ``` > [!NOTE] > > Messenger has a 24-hour messaging window: you can only reply to users who have messaged your Page within the last 24 hours. Marketplace buyer chats arrive through the same channel automatically — see the [Messenger guide](/guides/messenger/overview). *** ## Voice Channel Voice enables sending text-to-speech messages and receiving voicemails from callers. ### Requirements * Phone number with voice capability (purchased through Zavu) * Phone number must be assigned to the sender ### Capabilities > [card] Voice Messages > > Send short text-to-speech messages to recipients. Your text is converted to natural-sounding speech. > [card] Voicemail > > Receive voicemails that are automatically transcribed and appear in your inbox. ### Setup via Dashboard **1. Navigate to Voice Setup** In your sender's **Channels** tab, click **Add** next to Voice. **2. Verify Phone Number** Ensure your assigned phone number has voice capability enabled. **3. Configure Voicemail** Set up your voicemail greeting message that callers will hear. ### Sending Voice Messages Send text-to-speech messages to recipients. The text is converted to natural-sounding speech and delivered as a voice call. ```typescript TypeScript theme={null} await zavu.messages.send({ to: "+56912345678", channel: "voice", text: "Your verification code is 1 2 3 4 5 6", }); ``` ```python Python theme={null} zavu.messages.send( to="+56912345678", channel="voice", text="Your verification code is 1 2 3 4 5 6", ) ``` ```ruby Ruby theme={null} client.messages.send_( to: "+56912345678", channel: "voice", text: "Your verification code is 1 2 3 4 5 6" ) ``` ```go Go theme={null} client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("+56912345678"), Channel: zavudev.String("voice"), Text: zavudev.String("Your verification code is 1 2 3 4 5 6"), }) ``` ```php PHP theme={null} messages->send([ 'to' => '+56912345678', 'channel' => 'voice', 'text' => 'Your verification code is 1 2 3 4 5 6', ]); ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "+56912345678", "channel": "voice", "text": "Your verification code is 1 2 3 4 5 6" }' ``` > [!TIP] > > For verification codes, speak digits individually ("1 2 3 4 5 6") rather than as a number for better clarity. ### Receiving Voicemails When someone calls your Zavu number: 1. The caller hears your voicemail greeting 2. They leave a voice message 3. The message is automatically transcribed 4. It appears in your inbox with both audio and transcription Configure webhooks to receive `message.inbound` events with `channel: "voice"`. > [!NOTE] > > Voicemails are automatically transcribed. You can access both the original audio recording and the text transcription in your inbox and via the API. *** ## Verifying Channel Status ### Check via Dashboard Navigate to your sender's **Channels** tab to see the status of each channel: | Status | Meaning | | - | - | | **Active** | Channel is properly configured and ready | | **Pending** | Setup in progress or awaiting verification | | **Error** | Configuration issue - check details | | **Not Configured** | Channel has not been set up | ### Check via API ```typescript TypeScript theme={null} const sender = await zavu.senders.retrieve({ senderId: "sender_12345" }); console.log("Sender ID:", sender.id); console.log("Phone Number:", sender.phoneNumber); console.log("WhatsApp:", sender.whatsapp ? "Connected" : "Not configured"); console.log("Email Receiving:", sender.emailReceivingEnabled); ``` ```python Python theme={null} sender = zavu.senders.retrieve(sender_id="sender_12345") print(f"Sender ID: {sender.id}") print(f"Phone Number: {sender.phone_number}") print(f"WhatsApp: {'Connected' if sender.whatsapp else 'Not configured'}") print(f"Email Receiving: {sender.email_receiving_enabled}") ``` ```ruby Ruby theme={null} sender = client.senders.retrieve(sender_id: "sender_12345") puts "Sender ID: #{sender.id}" puts "Phone Number: #{sender.phone_number}" puts "WhatsApp: #{sender.whatsapp ? 'Connected' : 'Not configured'}" puts "Email Receiving: #{sender.email_receiving_enabled}" ``` ```go Go theme={null} sender, _ := client.Senders.Get(context.TODO(), "sender_12345") fmt.Println("Sender ID:", sender.ID) fmt.Println("Phone Number:", sender.PhoneNumber) if sender.WhatsApp != nil { fmt.Println("WhatsApp: Connected") } else { fmt.Println("WhatsApp: Not configured") } fmt.Println("Email Receiving:", sender.EmailReceivingEnabled) ``` ```php PHP theme={null} senders->retrieve('sender_12345'); echo "Sender ID: " . $sender->id . "\n"; echo "Phone Number: " . $sender->phoneNumber . "\n"; echo "WhatsApp: " . ($sender->whatsapp ? 'Connected' : 'Not configured') . "\n"; echo "Email Receiving: " . ($sender->emailReceivingEnabled ? 'true' : 'false') . "\n"; ``` ```bash cURL theme={null} curl https://api.zavu.dev/v1/senders/sender_12345 \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` *** ## Best Practices > [card] Enable Multiple Channels > > Configure both SMS and WhatsApp to enable automatic fallback when one channel fails. > [card] Use Smart Routing > > Let Zavu select the optimal channel to reduce costs by up to 80%. > [card] Set Up Webhooks > > Configure webhooks to receive inbound messages and delivery status updates. > [card] Verify Before Sending > > Test each channel with a single message before sending at scale. *** ## Troubleshooting ### SMS Issues | Issue | Solution | | - | - | | SMS not sending | Verify phone number has SMS capability | | Messages failing | Check 10DLC registration for US numbers | ### WhatsApp Issues | Issue | Solution | | - | - | | Verification code not appearing | Select **Call** verification for Zavu numbers | | Meta signup fails | Ensure Facebook Business account has proper permissions | | Can't send messages | Check if 24-hour window is open or use templates | ### Email Issues | Issue | Solution | | - | - | | Domain not verifying | Check DNS records are correctly configured | | Emails going to spam | Verify DKIM records are propagated | | Bounces | Verify recipient email addresses are valid | ### Telegram Issues | Issue | Solution | | - | - | | Bot token invalid | Regenerate token via BotFather using `/token` | | Messages not arriving | Verify webhook is registered (automatic on save) | | Can't message user | Users must initiate contact first | ### Instagram Issues | Issue | Solution | | - | - | | Connection failed | Verify Instagram account is a Business Account | | Messages failing | Check 24-hour messaging window | | Permission errors | Re-authorize Meta App permissions | ### Voice Issues | Issue | Solution | | - | - | | Voice not available | Verify phone number has voice capability | | Message not delivered | Check recipient answered the call | | No transcription | Voicemail may have been too short or unclear | *** ## Next Steps > [card] [Send Messages](/guides/sending-messages/easy-way) > > Start sending messages through your configured channels > [card] [Configure Webhooks](/guides/receiving-messages/webhooks) > > Set up webhooks to receive inbound messages > [card] [WhatsApp Templates](/guides/whatsapp/templates/overview) > > Create templates for WhatsApp outbound messaging > [card] [Smart Routing](/guides/sending-messages/smart-routing) > > Learn how Zavu optimizes channel selection # Easy Way Source: https://docs.zavu.dev/guides/sending-messages/easy-way The simplest way to send messages with Zavu Send your first message in seconds. Zavu handles channel selection, delivery optimization, and fallbacks automatically. ## Quick Start ```typescript TypeScript theme={null} import Zavudev from '@zavudev/sdk'; const zavu = new Zavudev({ apiKey: process.env['ZAVUDEV_API_KEY'], // This is the default and can be omitted }); const result = await zavu.messages.send({ to: "+14155551234", text: "Hello from Zavu!", }); ``` ```python Python theme={null} import os from zavudev import Zavudev zavu = Zavudev( api_key=os.environ.get("ZAVUDEV_API_KEY"), ) result = zavu.messages.send( to="+14155551234", text="Hello from Zavu!" ) ``` ```ruby Ruby theme={null} require "zavudev" client = Zavudev::Client.new(api_key: ENV["ZAVUDEV_API_KEY"]) result = client.messages.send_( to: "+14155551234", text: "Hello from Zavu!" ) ``` ```go Go theme={null} client := zavudev.NewClient(option.WithAPIKey(os.Getenv("ZAVUDEV_API_KEY"))) result, _ := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("+14155551234"), Text: zavudev.String("Hello from Zavu!"), }) ``` ```php PHP theme={null} messages->send([ 'to' => '+14155551234', 'text' => 'Hello from Zavu!', ]); ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "+14155551234", "text": "Hello from Zavu!" }' ``` > [!TIP] > > You can optionally specify a sender by passing `"Zavu-Sender": "snd_abc123"` inside the send params (TypeScript), `zavu_sender=` (Python), `zavu_sender:` (Ruby), `ZavuSender:` (Go), or the `Zavu-Sender` header (cURL). If omitted, your project's default sender is used. That's it. Zavu automatically: * **Respects user preference**: When a user messages you first, we reply on the same channel they used * **Optimizes outbound**: For business-initiated messages, selects the optimal channel based on cost and delivery rates * **Falls back intelligently**: Tries alternative channels if delivery fails * **Tracks everything**: Delivery status updates via API and webhooks ## How It Works When you send a message without specifying a channel, your **default Sender** handles routing: ``` Your Message → Default Sender → Smart Routing → Best Channel → Delivery ``` ### Senders A Sender is a configuration that defines how messages are sent. Each Sender has: * **Channels**: Which channels are available (SMS, WhatsApp, etc.) * **Routing Policy**: `smart` (automatic) or `manual` (you choose) * **Phone Numbers**: Configured for each channel > [!TIP] > > Create your first Sender in the [Dashboard](https://dashboard.zavu.dev/senders) or via the [API](/api-reference/senders/create). ## Using a Specific Sender If you have multiple Senders (e.g., "Marketing" and "Support"), specify which one to use: ```typescript TypeScript theme={null} const result = await zavu.messages.send({ to: "+14155551234", text: "Your order has shipped!", "Zavu-Sender": "snd_abc123", }); ``` ```python Python theme={null} result = zavu.messages.send( to="+14155551234", text="Your order has shipped!", zavu_sender="snd_abc123" ) ``` ```ruby Ruby theme={null} result = client.messages.send_( to: "+14155551234", text: "Your order has shipped!", zavu_sender: "snd_abc123" ) ``` ```go Go theme={null} result, _ := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("+14155551234"), Text: zavudev.String("Your order has shipped!"), ZavuSender: zavudev.String("snd_abc123"), }) ``` ```php PHP theme={null} messages->send([ 'to' => '+14155551234', 'text' => 'Your order has shipped!', ], ['zavu_sender' => 'snd_abc123']); ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Zavu-Sender: snd_abc123" \ -H "Content-Type: application/json" \ -d '{ "to": "+14155551234", "text": "Your order has shipped!" }' ``` ## Specifying a Channel For full control, specify the channel directly: ```typescript TypeScript theme={null} const result = await zavu.messages.send({ to: "+14155551234", text: "Hello!", channel: "whatsapp", }); ``` ```python Python theme={null} result = zavu.messages.send( to="+14155551234", text="Hello!", channel="whatsapp" ) ``` ```ruby Ruby theme={null} result = client.messages.send_( to: "+14155551234", text: "Hello!", channel: "whatsapp" ) ``` ```go Go theme={null} result, _ := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("+14155551234"), Text: zavudev.String("Hello!"), Channel: zavudev.String("whatsapp"), }) ``` ```php PHP theme={null} messages->send([ 'to' => '+14155551234', 'text' => 'Hello!', 'channel' => 'whatsapp', ]); ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "+14155551234", "text": "Hello!", "channel": "whatsapp" }' ``` Available channels: `sms`, `whatsapp` ## Response A successful request returns the message details: ```json theme={null} { "message": { "id": "msg_abc123", "to": "+14155551234", "text": "Hello from Zavu!", "channel": "whatsapp", "status": "queued", "createdAt": "2024-01-15T10:30:00.000Z" } } ``` | Status | Description | | - | - | | `queued` | Message accepted, pending delivery | | `sent` | Sent to carrier/provider | | `delivered` | Confirmed delivered | | `read` | User opened the message (WhatsApp only) | | `failed` | Delivery failed | ## Next Steps * [Smart Routing](/guides/sending-messages/smart-routing) - Learn how automatic channel selection saves up to 90% on messaging costs * [SMS Guide](/guides/sending-messages/sms) - SMS-specific features and best practices * [WhatsApp Guide](/guides/whatsapp/overview) - Rich messaging with media, buttons, and templates ## API Reference * [Send a message](/api-reference/send-a-message) — full HTTP reference for the `/v1/messages` endpoint * [Get a message by ID](/api-reference/get-message-by-id) — retrieve delivery status and metadata * [List messages](/api-reference/list-messages) — fetch sent messages with filtering and pagination # Sending Emails Source: https://docs.zavu.dev/guides/sending-messages/email Send transactional emails to any email address Email is ideal for rich content delivery, transactional notifications, and messages that need to be referenced later. ## When to Use Email * **Transactional emails**: Order confirmations, receipts, shipping updates * **Account notifications**: Password resets, security alerts, welcome emails * **Rich content**: HTML formatting, images, detailed information * **File attachments**: Invoices, receipts, reports, documents (up to 40MB) * **Asynchronous communication**: Messages that don't require immediate attention > [!NOTE] > > Email operates as a separate channel and does not participate in smart routing. You must explicitly specify `channel: "email"` or send to an email address. ## Sending an Email ```typescript TypeScript theme={null} const result = await zavu.messages.send({ to: "user@example.com", channel: "email", subject: "Your order has shipped", text: "Hi John, your order #12345 has shipped and will arrive in 2-3 business days." }); ``` ```python Python theme={null} result = zavu.messages.send( to="user@example.com", channel="email", subject="Your order has shipped", text="Hi John, your order #12345 has shipped and will arrive in 2-3 business days." ) ``` ```ruby Ruby theme={null} result = client.messages.send_( to: "user@example.com", channel: "email", subject: "Your order has shipped", text: "Hi John, your order #12345 has shipped and will arrive in 2-3 business days." ) ``` ```go Go theme={null} result, _ := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("user@example.com"), Channel: zavudev.String("email"), Subject: zavudev.String("Your order has shipped"), Text: zavudev.String("Hi John, your order #12345 has shipped and will arrive in 2-3 business days."), }) ``` ```php PHP theme={null} messages->send([ 'to' => 'user@example.com', 'channel' => 'email', 'subject' => 'Your order has shipped', 'text' => 'Hi John, your order #12345 has shipped and will arrive in 2-3 business days.', ]); ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "user@example.com", "channel": "email", "subject": "Your order has shipped", "text": "Hi John, your order #12345 has shipped and will arrive in 2-3 business days." }' ``` > [!TIP] > > You can optionally specify a sender by passing `"Zavu-Sender": "snd_abc123"` inside the send params (TypeScript), `zavu_sender=` (Python), `zavu_sender:` (Ruby), `ZavuSender:` (Go), or the `Zavu-Sender` header (cURL). If omitted, your project's default sender is used. ## HTML Emails Send rich HTML emails by including `htmlBody`. The `text` field serves as the plain-text fallback: ```typescript TypeScript theme={null} const result = await zavu.messages.send({ to: "user@example.com", channel: "email", subject: "Welcome to Zavu", text: "Welcome to Zavu! We are excited to have you on board.", htmlBody: `

Welcome to Zavu!

We are excited to have you on board.

Get Started `, replyTo: "support@yourcompany.com" }); ``` ```python Python theme={null} result = zavu.messages.send( to="user@example.com", channel="email", subject="Welcome to Zavu", text="Welcome to Zavu! We are excited to have you on board.", html_body="""

Welcome to Zavu!

We are excited to have you on board.

Get Started """, reply_to="support@yourcompany.com" ) ``` ```ruby Ruby theme={null} result = client.messages.send_( to: "user@example.com", channel: "email", subject: "Welcome to Zavu", text: "Welcome to Zavu! We are excited to have you on board.", html_body: "

Welcome to Zavu!

We are excited to have you on board.

", reply_to: "support@yourcompany.com" ) ``` ```go Go theme={null} result, _ := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("user@example.com"), Channel: zavudev.String("email"), Subject: zavudev.String("Welcome to Zavu"), Text: zavudev.String("Welcome to Zavu! We are excited to have you on board."), HTMLBody: zavudev.String("

Welcome to Zavu!

We are excited to have you on board.

"), ReplyTo: zavudev.String("support@yourcompany.com"), }) ``` ```php PHP theme={null} messages->send([ 'to' => 'user@example.com', 'channel' => 'email', 'subject' => 'Welcome to Zavu', 'text' => 'Welcome to Zavu! We are excited to have you on board.', 'htmlBody' => '

Welcome to Zavu!

We are excited to have you on board.

', 'replyTo' => 'support@yourcompany.com', ]); ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "user@example.com", "channel": "email", "subject": "Welcome to Zavu", "text": "Welcome to Zavu! We are excited to have you on board.", "htmlBody": "

Welcome to Zavu!

We are excited to have you on board.

", "replyTo": "support@yourcompany.com" }' ``` ## Attachments Send files by including the `attachments` array. Each attachment can provide file content as base64 or a URL to fetch from. ### Base64 Content ```typescript TypeScript theme={null} const fs = require("fs"); const content = fs.readFileSync("invoice.pdf").toString("base64"); const result = await zavu.messages.send({ to: "user@example.com", channel: "email", subject: "Your invoice", text: "Please find your invoice attached.", attachments: [ { filename: "invoice.pdf", content: content, content_type: "application/pdf", }, ], }); ``` ```python Python theme={null} import base64 with open("invoice.pdf", "rb") as f: content = base64.b64encode(f.read()).decode() result = zavu.messages.send( to="user@example.com", channel="email", subject="Your invoice", text="Please find your invoice attached.", attachments=[ { "filename": "invoice.pdf", "content": content, "content_type": "application/pdf", } ], ) ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "user@example.com", "channel": "email", "subject": "Your invoice", "text": "Please find your invoice attached.", "attachments": [ { "filename": "invoice.pdf", "content": "JVBERi0xLjQK...", "content_type": "application/pdf" } ] }' ``` ### URL Path Instead of encoding the file, provide a URL and Zavu will fetch it: ```typescript TypeScript theme={null} const result = await zavu.messages.send({ to: "user@example.com", channel: "email", subject: "Your report", text: "Please find your report attached.", attachments: [ { filename: "report.pdf", path: "https://your-server.com/reports/report.pdf", }, ], }); ``` ```python Python theme={null} result = zavu.messages.send( to="user@example.com", channel="email", subject="Your report", text="Please find your report attached.", attachments=[ { "filename": "report.pdf", "path": "https://your-server.com/reports/report.pdf", } ], ) ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "user@example.com", "channel": "email", "subject": "Your report", "text": "Please find your report attached.", "attachments": [ { "filename": "report.pdf", "path": "https://your-server.com/reports/report.pdf" } ] }' ``` ### Inline Images Embed images directly in HTML using `content_id`: ```typescript TypeScript theme={null} const result = await zavu.messages.send({ to: "user@example.com", channel: "email", subject: "Your weekly report", text: "See your weekly report.", htmlBody: `

Weekly Report

Performance chart

Your performance improved 15% this week.

`, attachments: [ { filename: "chart.png", path: "https://your-server.com/charts/weekly.png", content_type: "image/png", content_id: "chart", }, ], }); ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "user@example.com", "channel": "email", "subject": "Your weekly report", "text": "See your weekly report.", "htmlBody": "

Weekly Report

Improved 15%.

", "attachments": [ { "filename": "chart.png", "path": "https://your-server.com/charts/weekly.png", "content_type": "image/png", "content_id": "chart" } ] }' ``` ### Attachment Fields | Field | Required | Description | | - | - | - | | `filename` | Yes | Name of the file | | `content` | One of | Base64-encoded file content | | `path` | One of | URL to fetch the file from | | `content_type` | No | MIME type (auto-detected from filename if omitted) | | `content_id` | No | ID for inline images, reference in HTML as `cid:your_id` | > [!NOTE] > > Each attachment must have either `content` or `path`, not both. Total attachment size is limited to **40MB** per email, and a maximum of **50 attachments** per message. ## Request Fields | Field | Required | Description | | - | - | - | | `to` | Yes | Valid email address | | `channel` | Yes | Must be `"email"` | | `subject` | Yes | Email subject line (max 998 chars) | | `text` | Yes | Plain text body | | `htmlBody` | No | HTML version of the email | | `replyTo` | No | Reply-To email address | | `attachments` | No | Array of file attachments (email only) | ## Channel Detection If you send to an email address without specifying a channel, Zavu automatically selects email: ```typescript TypeScript theme={null} const result = await zavu.messages.send({ to: "user@example.com", subject: "Hello", text: "This will be sent as email automatically" }); ``` ```python Python theme={null} result = zavu.messages.send( to="user@example.com", subject="Hello", text="This will be sent as email automatically" ) ``` ```ruby Ruby theme={null} result = client.messages.send_( to: "user@example.com", subject: "Hello", text: "This will be sent as email automatically" ) ``` ```go Go theme={null} result, _ := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("user@example.com"), Subject: zavudev.String("Hello"), Text: zavudev.String("This will be sent as email automatically"), }) ``` ```php PHP theme={null} messages->send([ 'to' => 'user@example.com', 'subject' => 'Hello', 'text' => 'This will be sent as email automatically', ]); ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "user@example.com", "subject": "Hello", "text": "This will be sent as email automatically" }' ``` ## Setting Up Email Before sending emails, you need to configure your sender with a verified domain. ### 1. Add Your Domain Go to **Email Domains** in the dashboard and add your domain (e.g., `yourcompany.com`). ### 2. Configure DNS Add the DKIM records provided by Zavu to your DNS: ``` selector1._domainkey.yourcompany.com CNAME selector1.dkim.zavu.dev selector2._domainkey.yourcompany.com CNAME selector2.dkim.zavu.dev selector3._domainkey.yourcompany.com CNAME selector3.dkim.zavu.dev ``` > [!TIP] > > DNS propagation typically takes a few minutes, but can take up to 48 hours in some cases. ### 3. Verify Domain Zavu automatically checks your DNS records every 5 minutes. Once verified, your domain status will change to `verified`. ### 4. Assign to Sender Assign the verified domain to a sender and configure your from address: * **From address**: `noreply@yourcompany.com` * **Reply-To address**: `support@yourcompany.com` (optional) ## Delivery Status Check message status by ID: ```typescript TypeScript theme={null} const result = await zavu.messages.retrieve({ messageId: "msg_abc123" }); console.log("Status:", result.message.status); ``` ```python Python theme={null} result = zavu.messages.retrieve(message_id="msg_abc123") print(f"Status: {result.message.status}") ``` ```ruby Ruby theme={null} result = client.messages.retrieve(message_id: "msg_abc123") puts "Status: #{result.message.status}" ``` ```go Go theme={null} result, _ := client.Messages.Get(context.TODO(), "msg_abc123") fmt.Println("Status:", result.Message.Status) ``` ```php PHP theme={null} messages->retrieve('msg_abc123'); echo "Status: " . $result->message->status; ``` ```bash cURL theme={null} curl https://api.zavu.dev/v1/messages/msg_abc123 \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` | Status | Description | | - | - | | `queued` | Accepted, pending delivery | | `sending` | Being sent to recipient | | `delivered` | Confirmed delivered | | `failed` | Delivery failed | ## Common Errors | Error | Description | Solution | | - | - | - | | `subject_required` | Missing subject line | Include `subject` field | | `sender_not_configured` | Sender lacks email setup | Configure email domain on sender | | `domain_not_verified` | Domain not verified | Complete DNS verification | | `bounce` | Email bounced | Invalid or non-existent email address | | `complaint` | Marked as spam | Review email content and sending practices | ## Email vs SMS/WhatsApp | Feature | Email | SMS / WhatsApp | | - | - | - | | **Smart Routing** | No - explicit channel | Yes - auto-selected | | **Automatic Fallback** | No | Yes | | **Recipient Type** | Email address | Phone number (E.164) | | **Rich Content** | Full HTML support | Limited (WhatsApp only) | | **File Attachments** | Yes (up to 40MB) | Media only (WhatsApp) | | **Delivery Speed** | Minutes | Seconds | > [!WARNING] > > Email is a separate channel and does not fall back to SMS/WhatsApp if delivery fails. ## Best Practices 1. **Always include plain text** - Some email clients only show plain text 2. **Use a verified domain** - Improves deliverability and avoids spam filters 3. **Set Reply-To** - Make it easy for recipients to respond 4. **Keep subjects clear** - Descriptive subjects improve open rates 5. **Test before sending** - Preview HTML emails in multiple clients 6. **Respect unsubscribes** - Honor opt-out requests promptly ## Compliance > [!WARNING] > > Email messaging is regulated. Ensure you comply with CAN-SPAM, GDPR, and other applicable laws. ### Requirements * **Consent**: Have permission before sending marketing emails * **Unsubscribe**: Include a working unsubscribe link * **Sender identification**: Clearly identify your business * **Physical address**: Include your business address (for marketing emails) * **Honest subject lines**: Don't use deceptive subject lines ### Example Compliant Email ```html theme={null}

Your Order Has Shipped!

Hi John, your order #12345 has shipped.

Track your package: View Tracking


YourCompany, Inc. | 123 Main St, San Francisco, CA 94102
Unsubscribe

``` # Smart Routing Source: https://docs.zavu.dev/guides/sending-messages/smart-routing Automatic channel selection that reduces costs up to 80% Smart Routing is Zavu's system that automatically selects the optimal channel for each message, maximizing delivery rates while minimizing costs. > [!NOTE] > > **Smart routing only applies to outbound messages.** When replying to an inbound message, Zavu always responds on the **same channel** the contact used. ## When Smart Routing Applies | Message Type | Routing Behavior | | - | - | | **Outbound** (you initiate) | Smart routing selects optimal channel | | **Inbound reply** (direct response) | Always use the contact's channel | ## How It Works For outbound messages, the router checks if the contact has previously messaged you: ``` Has contact messaged via WhatsApp in last 24h? | +--+--+ | | Yes No | | v v WhatsApp WhatsApp only available with template (preferred) ``` Since WhatsApp is cheaper than SMS, the router **prefers WhatsApp when the 24-hour window is open**. ## Channel Selection Algorithm The router evaluates channels in this order: 1. **Filter viable channels**: Remove channels that can't deliver (e.g., WhatsApp without open window) 2. **Sort by cost**: Cheapest channels first 3. **Check success rate**: Channel must have at least 80% success rate (or fewer than 3 attempts for exploration) 4. **Select best option**: First channel meeting all criteria ## Cost Optimization | Channel | Approximate Cost | | - | - | | WhatsApp | \~\$0.01/message | | SMS | \~\$0.05/message | Smart routing can reduce messaging costs by **up to 80%** by preferring WhatsApp when available. ## WhatsApp 24-Hour Window WhatsApp has a strict rule: you can only send free-form messages within **24 hours** of the last message received from the contact. ``` Contact sends message ... 24-hour window opens | Within 24h: Send any message type | After 24h: Only template messages allowed ``` > [!WARNING] > > If the 24-hour window is closed and you don't have a template configured, the smart router will automatically select SMS instead of failing. ## Channel Metrics Zavu tracks delivery metrics for each contact and channel: ```json theme={null} { "channelMetrics": { "sms": { "successCount": 45, "failureCount": 2, "avgDeliveryTimeMs": 3200, "lastSuccessAt": "2025-01-15T10:30:00Z" }, "whatsapp": { "successCount": 120, "failureCount": 1, "avgDeliveryTimeMs": 1100, "lastSuccessAt": "2025-01-15T14:22:00Z" } } } ``` The router uses these metrics to: * Avoid channels with low success rates (below 80%) * Prefer faster channels when speed matters * Explore new channels (first 3 attempts get a chance regardless of stats) ## Automatic Fallback When a message fails on one channel, Zavu **automatically retries** on an alternate channel. ### Fallback Flow ``` Send via WhatsApp | v Failed? | +--+--+ | | No Yes | | v v Done Wait 5s | v Retry via SMS | v Success? | +--+--+ | | Yes No | | v v Done Mark Failed ``` ### Fallback Rules | Rule | Description | | - | - | | Max 2 attempts | Each message is tried on at most 2 channels | | 5-second delay | Wait 5 seconds before retry to avoid race conditions | | No duplicate channels | Never retry on a channel that already failed | | Sender must support both | Fallback only works if sender has both channels configured | ### Tracking Fallback A message that fell back keeps its id and reports the channel it was finally sent on: ```json theme={null} { "id": "msg_abc123", "status": "delivered", "channel": "sms" } ``` In this example, the message first tried WhatsApp, failed, and was delivered via SMS. Fallback is on by default only when Zavu picks the channel. A send with an explicit `channel` does not fall back unless it sets `fallbackEnabled: true`; see [Senders](/concepts/senders#enablingdisabling-fallback). ## Manual Override To bypass Smart Routing for a specific message, specify the channel explicitly: ```typescript TypeScript theme={null} await zavu.messages.send({ to: "+56912345678", text: "This must go via SMS", channel: "sms" }); ``` ```python Python theme={null} zavu.messages.send( to="+56912345678", text="This must go via SMS", channel="sms" ) ``` ```ruby Ruby theme={null} client.messages.send_( to: "+56912345678", text: "This must go via SMS", channel: "sms" ) ``` ```go Go theme={null} client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("+56912345678"), Text: zavudev.String("This must go via SMS"), Channel: zavudev.String("sms"), }) ``` ```php PHP theme={null} messages->send([ 'to' => '+56912345678', 'text' => 'This must go via SMS', 'channel' => 'sms', ]); ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "+56912345678", "text": "This must go via SMS", "channel": "sms" }' ``` > [!TIP] > > You can optionally specify a sender by passing `"Zavu-Sender": "snd_abc123"` inside the send params (TypeScript), `zavu_sender=` (Python), `zavu_sender:` (Ruby), `ZavuSender:` (Go), or the `Zavu-Sender` header (cURL). If omitted, your project's default sender is used. ## Best Practices > [card] Configure Multiple Channels > > Set up both SMS and WhatsApp on your sender to enable automatic fallback. > [card] Use Templates for Outreach > > When initiating conversations, use WhatsApp templates to avoid 24-hour window issues. > [card] Monitor Metrics > > Check your contact metrics to understand channel performance in your region. > [card] Keep Numbers Updated > > Invalid numbers waste attempts across all channels. ## Next Steps > [card] [Senders](/concepts/senders) > > Learn more about sender configuration > [card] [WhatsApp Templates](/guides/whatsapp/templates/sending) > > Create templates for outbound messaging # Send SMS Messages with Zavu Source: https://docs.zavu.dev/guides/sending-messages/sms Send SMS messages programmatically to any phone number worldwide. REST API, SDKs for Python, TypeScript, Go, Ruby and PHP, smart routing across carriers. SMS (Short Message Service) is the most universal messaging channel, reaching virtually any mobile phone without requiring an internet connection or app installation. ## When to Use SMS * **Verification codes**: OTPs, 2FA * **Transactional alerts**: Order confirmations, shipping updates * **Time-sensitive notifications**: Appointment reminders * **Universal reach**: When you need to reach users without smartphones ## Sending an SMS ```typescript TypeScript theme={null} const result = await zavu.messages.send({ to: "+14155551234", text: "Your verification code is 123456", channel: "sms", }); ``` ```python Python theme={null} result = zavu.messages.send( to="+14155551234", text="Your verification code is 123456", channel="sms" ) ``` ```ruby Ruby theme={null} result = client.messages.send_( to: "+14155551234", text: "Your verification code is 123456", channel: "sms" ) ``` ```go Go theme={null} result, _ := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("+14155551234"), Text: zavudev.String("Your verification code is 123456"), Channel: zavudev.String("sms"), }) ``` ```php PHP theme={null} messages->send([ 'to' => '+14155551234', 'text' => 'Your verification code is 123456', 'channel' => 'sms', ]); ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "+14155551234", "text": "Your verification code is 123456", "channel": "sms" }' ``` > [!TIP] > > You can optionally specify a sender by passing `zavuSender` (TypeScript), `zavu_sender` (Python), or the `Zavu-Sender` header (cURL). If omitted, your project's default sender is used. ## Message Length SMS messages are limited to 160 characters (GSM-7 encoding) or 70 characters (Unicode). Longer messages are split into multiple segments. | Encoding | Single SMS | Concatenated SMS | | - | - | - | | GSM-7 | 160 chars | 153 chars/segment | | Unicode | 70 chars | 67 chars/segment | > [!TIP] > > Keep messages under 160 characters when possible to avoid multi-segment charges. ## Character Encoding GSM-7 supports basic Latin characters. Using emojis, accents, or non-Latin scripts triggers Unicode encoding: ``` GSM-7: "Your code is 123456" (20 chars, 1 segment) Unicode: "Your code is 123456" (22 chars, 1 segment with special chars) Unicode: "Tu codigo es 123456" (19 chars, 1 segment, 'o' with accent triggers Unicode) ``` ### GSM-7 Character Set ``` A-Z a-z 0-9 @ $ _ ! " # % & ' ( ) * + , - . / : ; < = > ? Space, newline, carriage return ``` Any character outside this set triggers Unicode encoding. ## Sender ID Some countries support alphanumeric sender IDs (e.g., "ZAVU" instead of a phone number). > [!WARNING] > > Alphanumeric sender IDs cannot receive replies. Use a phone number if you need two-way SMS. ### Supported Countries | Region | Alphanumeric Sender ID | | - | - | | US/Canada | Not supported (10DLC or short code required) | | UK | Supported | | EU | Mostly supported | | LATAM | Varies by country | ## SMS Templates Send pre-defined templates via SMS by specifying `channel: "sms"` and `messageType: "template"`: ```typescript TypeScript theme={null} const result = await zavu.messages.send({ to: "+14155551234", channel: "sms", messageType: "template", content: { templateId: "tpl_abc123", templateVariables: { "1": "John", "2": "ORD-12345", }, }, }); ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "+14155551234", "channel": "sms", "messageType": "template", "content": { "templateId": "tpl_abc123", "templateVariables": { "1": "John", "2": "ORD-12345" } } }' ``` If the template has an `smsBody`, it will be used instead of the default `body`. Contact variables like `{{contact.first_name}}` are automatically resolved from the recipient's contact metadata. ## Delivery Status Check message status by ID: ```bash theme={null} curl https://api.zavu.dev/v1/messages/msg_abc123 \ -H "Authorization: Bearer zv_live_xxx" ``` | Status | Description | | - | - | | `queued` | Accepted, pending delivery | | `sent` | Sent to carrier | | `delivered` | Confirmed delivered | | `failed` | Delivery failed | ## Common Errors | Error Code | Description | Solution | | - | - | - | | `30003` | Unreachable destination | Invalid number or phone off | | `30004` | Message blocked | Content filtered by carrier | | `30005` | Unknown destination | Number doesn't exist | | `30006` | Landline destination | Cannot send SMS to landlines | | `30007` | Carrier violation | Message rejected by carrier | ## Compliance > [!WARNING] > > SMS messaging is regulated. Ensure you have consent before sending messages. ### Requirements * **Opt-in consent**: Users must explicitly agree to receive SMS * **Opt-out mechanism**: Include instructions to unsubscribe (e.g., "Reply STOP to unsubscribe") * **Business identification**: Identify your business in the message * **Time restrictions**: Avoid sending during quiet hours (typically 9 PM - 9 AM local time) ### Content check Every SMS and one-way SMS with text is read before it is sent. A message the check refuses is answered right away with `422` and code `sms_compliance_blocked`; nothing is sent or charged. One-time codes (verification codes) always pass. ```json theme={null} { "code": "sms_compliance_blocked", "message": "SMS blocked: The message asks for a payment through a link without identifying the sender.", "details": { "riskScore": 85, "messageType": "marketing", "complianceFailures": [ { "code": "MISSING_SENDER_ID", "severity": "high", "title": "Sender not identified" } ], "requiredFixes": ["Name your business in the message."], "suggestedCompliantVersion": "[YourBrand] Your invoice is ready: https://yourbrand.com/pay/123" } } ``` Apply `requiredFixes`, or send `suggestedCompliantVersion` when there is one, and send again. If the check itself could not run, `message` says so; retry in a few minutes. > [!NOTE] > > This error used to arrive wrapped, as `{"error": {"code": ..., "message": ..., "details": ...}}`. > It now has the same flat `{code, message, details}` shape as every other API > error. If your code reads `error.code` for this case, read `code` instead. ### Example Compliant Message ``` [YourBrand] Your order #12345 has shipped! Track: https://track.co/abc Reply STOP to unsubscribe ``` ## Best Practices 1. **Keep it short** - Aim for under 160 characters 2. **Identify yourself** - Start with your brand name 3. **Be actionable** - Include clear next steps or links 4. **Respect timing** - Send during business hours in the recipient's timezone 5. **Provide opt-out** - Always include unsubscribe instructions for marketing messages # Creating Sub-Accounts Source: https://docs.zavu.dev/guides/sub-accounts/creating Provision sub-accounts for your customers with API keys and spending controls > [!WARNING] > > You must use a **main account** API key to create sub-accounts. Sub-account API keys cannot create new sub-accounts (no nesting) and receive `HTTP 403` if they try. ## Create a Sub-Account ```typescript TypeScript theme={null} import Zavu from "@zavudev/sdk"; const zavu = new Zavu({ apiKey: "zv_live_xxx" }); const { subAccount } = await zavu.subAccounts.create({ name: "Client ABC", externalId: "client_123", creditLimit: 100000, // $1,000.00 in cents metadata: { plan: "enterprise", region: "us-east", }, }); // Save this — only shown once console.log("API Key:", subAccount.apiKey); ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/sub-accounts \ -H "Authorization: Bearer zv_live_xxx" \ -H "Content-Type: application/json" \ -d '{ "name": "Client ABC", "externalId": "client_123", "creditLimit": 100000, "metadata": { "plan": "enterprise", "region": "us-east" } }' ``` ### Parameters | Parameter | Type | Required | Description | | - | - | - | - | | `name` | string | Yes | Name of the sub-account (max 200 characters) | | `externalId` | string | No | Your own reference ID for tracking | | `creditLimit` | integer | No | Spending cap in cents. `0` or omitted = no limit | | `metadata` | object | No | Arbitrary key-value pairs for your own use | ### Response The API key is **only returned in the creation response**. Store it securely. ```json theme={null} { "subAccount": { "id": "jx7abc123def456", "name": "Client ABC", "externalId": "client_123", "status": "active", "totalSpent": 0, "creditLimit": 100000, "metadata": { "plan": "enterprise", "region": "us-east" }, "apiKey": "zv_live_sub_xxxxxxxxxxxx", "createdAt": "2025-01-15T10:00:00Z" } } ``` > [!WARNING] > > The `apiKey` field is only included in the creation response. If you lose it, you'll need to create a new API key for the sub-account. ## Credit Limits Credit limits act as a spending cap in **cents**. When a sub-account's `totalSpent` reaches the `creditLimit`, all messaging is blocked. | Credit Limit | Behavior | | - | - | | Omitted or `0` | No spending cap — uses full team balance | | `100000` | Blocks messaging after \$1,000.00 spent | | `500` | Blocks messaging after \$5.00 spent | > [!NOTE] > > Credit limits protect your balance from unexpected usage. You can update or remove them at any time via `PATCH /v1/sub-accounts/:id`. ## External IDs The `externalId` field lets you map sub-accounts to your own system: ```typescript theme={null} // Create with your internal customer ID const { subAccount } = await zavu.subAccounts.create({ name: "Acme Corp", externalId: "cust_abc123", // Your CRM customer ID }); ``` An `externalId` is unique within your team: creating or updating a sub-account with one another sub-account already has returns `409 conflict`. This is useful for: * Mapping sub-accounts to your CRM records * Billing reconciliation * Audit trails ## Metadata Store arbitrary key-value data with the sub-account: ```typescript theme={null} const { subAccount } = await zavu.subAccounts.create({ name: "Client ABC", metadata: { plan: "enterprise", billingEmail: "billing@client-abc.com", salesRep: "jane@yourcompany.com", }, }); ``` ## One Main Account Rule Each team is limited to **one main account**. All additional projects must be sub-accounts. ``` Team "My Company" ├── Main Account ← created automatically when the team is created ├── Sub-Account: Client A ✓ ├── Sub-Account: Client B ✓ └── Main Account ✗ (rejected — use a sub-account or create a new team) ``` If you try to create a second main account, the API returns an error: ```json theme={null} { "code": "invalid_request", "message": "Team already has a main account. Create a sub-account instead, or create a new team for another main account." } ``` ## Next Steps > [card] [Managing Sub-Accounts](/guides/sub-accounts/managing) > > Update credit limits, rotate API keys, and monitor spending > [card] [Deletion & Deactivation](/guides/sub-accounts/deletion) > > Deactivate sub-accounts and understand the grace period # Deletion & Deactivation Source: https://docs.zavu.dev/guides/sub-accounts/deletion Deactivate sub-accounts and understand the 14-day deletion grace period ## Deactivate a Sub-Account (API) The `DELETE /v1/sub-accounts/:id` endpoint **deactivates** a sub-account immediately. All API keys are revoked and messaging stops. ```typescript TypeScript theme={null} const result = await zavu.subAccounts.deactivate("jx7abc123def456"); console.log(`Keys revoked: ${result.keysRevoked}`); ``` ```bash cURL theme={null} curl -X DELETE https://api.zavu.dev/v1/sub-accounts/jx7abc123def456 \ -H "Authorization: Bearer zv_live_xxx" ``` Response: ```json theme={null} { "message": "Sub-account deactivated", "keysRevoked": 2 } ``` > [!NOTE] > > Deactivation via the API revokes all API keys and sets the sub-account status to `pending_deletion`. It schedules the same 14-day deletion as the dashboard: the sub-account is removed after 14 days unless it is restored from the dashboard first. Calling it again on a sub-account already `pending_deletion` changes nothing. To pause a sub-account without scheduling deletion, set its status to `inactive` with `PATCH /v1/sub-accounts/:id` instead (see [Managing](/guides/sub-accounts/managing)). ## Delete a Sub-Account (Dashboard) Permanent deletion is available from the Zavu dashboard and follows a **14-day grace period** to prevent accidental data loss. ### How It Works **1. Schedule deletion** From the dashboard workspace switcher, select the sub-account and choose "Delete". This schedules deletion but does not remove any data yet. **2. API keys are revoked** All API keys for the sub-account are revoked immediately. The sub-account can no longer send or receive messages. **3. Email notifications** The team owner receives email notifications at multiple intervals: | When | Email | | - | - | | Day 0 | Deletion scheduled notification | | Day 1 | Reminder: 13 days remaining | | Day 7 | Reminder: 7 days remaining | | Day 12 | Urgent: 2 days remaining | | Day 14 | Sub-account permanently deleted | **4. Restore or wait** During the 14-day grace period, you can restore the sub-account from the dashboard. After 14 days, all data is permanently deleted. ### Restoring a Deleted Sub-Account If you change your mind during the 14-day grace period: 1. Open the dashboard workspace switcher 2. Find the sub-account marked as "Pending Deletion" 3. Click "Restore" The sub-account returns to `active` status. You'll need to create new API keys since the originals were revoked. > [!WARNING] > > After 14 days, deletion is **permanent and irreversible**. All messages, contacts, and configuration associated with the sub-account are removed. ## Deletion Rules * You **cannot delete the last active sub-account** in a team. At least one project must remain active. * The main account follows the same deletion rules as sub-accounts. * Scheduled reminder emails are automatically cancelled if the sub-account is restored. ## Deactivation vs Deletion | Action | API Keys | Data | Reversible | Method | | - | - | - | - | - | | **Pause** (API) | Kept | Preserved | Yes — set status to `active` | `PATCH /v1/sub-accounts/:id` with `"status": "inactive"` | | **Deactivate** (API) | Revoked | Removed after 14 days | Yes — restore from the dashboard within 14 days | `DELETE /v1/sub-accounts/:id` | | **Delete** (Dashboard) | Revoked | Removed after 14 days | Yes — within 14 days | Dashboard UI | ## Next Steps > [card] [Creating Sub-Accounts](/guides/sub-accounts/creating) > > Create new sub-accounts to replace deactivated ones > [card] [Managing Sub-Accounts](/guides/sub-accounts/managing) > > Monitor spending and manage API keys # Managing Sub-Accounts Source: https://docs.zavu.dev/guides/sub-accounts/managing Update credit limits, manage API keys, and monitor sub-account spending > [!WARNING] > > All endpoints under `/v1/sub-accounts` require a **main account** API key. A sub-account API key calling these endpoints receives `HTTP 403`. Sub-accounts can read their own balance via `GET /v1/balance`. ## List Sub-Accounts ```typescript TypeScript theme={null} const { items, nextCursor } = await zavu.subAccounts.list({ limit: 50, }); for (const sub of items) { console.log(`${sub.name}: $${(sub.totalSpent / 100).toFixed(2)} spent`); } ``` ```bash cURL theme={null} curl https://api.zavu.dev/v1/sub-accounts?limit=50 \ -H "Authorization: Bearer zv_live_xxx" ``` ## Update a Sub-Account Update the name, credit limit, external ID, metadata, or status: ```typescript TypeScript theme={null} const { subAccount } = await zavu.subAccounts.update("jx7abc123def456", { creditLimit: 200000, // Increase to $2,000 metadata: { plan: "enterprise-plus" }, }); ``` ```bash cURL theme={null} curl -X PATCH https://api.zavu.dev/v1/sub-accounts/jx7abc123def456 \ -H "Authorization: Bearer zv_live_xxx" \ -H "Content-Type: application/json" \ -d '{ "creditLimit": 200000, "metadata": { "plan": "enterprise-plus" } }' ``` ### Updatable Fields | Field | Type | Description | | - | - | - | | `name` | string | Sub-account display name | | `externalId` | string | Your reference ID | | `creditLimit` | integer \| null | Spending cap in cents. Set to `null` or `0` to remove | | `metadata` | object | Arbitrary key-value data | | `status` | string | `"active"` or `"inactive"` | ## Monitor Spending ### Check a Sub-Account's Balance ```typescript TypeScript theme={null} const balance = await zavu.subAccounts.getBalance("jx7abc123def456"); console.log(`Team balance: $${(balance.balance / 100).toFixed(2)}`); console.log(`Sub-account spent: $${(balance.totalSpent / 100).toFixed(2)}`); console.log(`Credit limit: $${(balance.creditLimit / 100).toFixed(2)}`); ``` ```bash cURL theme={null} curl https://api.zavu.dev/v1/sub-accounts/jx7abc123def456/balance \ -H "Authorization: Bearer zv_live_xxx" ``` Response: ```json theme={null} { "balance": 150000, "currency": "usd", "creditLimit": 100000, "totalSpent": 45000, "isSubAccount": true } ``` ### Check Balance from the Sub-Account's Perspective When a sub-account uses its own API key to call `GET /v1/balance`, it sees the team balance plus its own spending data: ```bash theme={null} curl https://api.zavu.dev/v1/balance \ -H "Authorization: Bearer zv_live_sub_xxxxxxxxxxxx" ``` ```json theme={null} { "balance": 150000, "currency": "usd", "creditLimit": 100000, "totalSpent": 45000, "isSubAccount": true } ``` ## Manage API Keys Each sub-account can have multiple API keys. Create additional keys or revoke compromised ones. ### List API Keys ```typescript TypeScript theme={null} const { items } = await zavu.subAccounts.apiKeys.list("jx7abc123def456"); for (const key of items) { console.log(`${key.name} (${key.keyPrefix}...): ${key.environment}`); } ``` ```bash cURL theme={null} curl https://api.zavu.dev/v1/sub-accounts/jx7abc123def456/api-keys \ -H "Authorization: Bearer zv_live_xxx" ``` ### Create an API Key ```typescript TypeScript theme={null} const { apiKey } = await zavu.subAccounts.apiKeys.create("jx7abc123def456", { name: "Production Key", environment: "live", }); // Save this — only shown once console.log("New key:", apiKey.key); ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/sub-accounts/jx7abc123def456/api-keys \ -H "Authorization: Bearer zv_live_xxx" \ -H "Content-Type: application/json" \ -d '{"name": "Production Key", "environment": "live"}' ``` > [!WARNING] > > The full API key is only returned once in the creation response. Store it securely. ### Revoke an API Key ```typescript TypeScript theme={null} // The key id comes first; the sub-account it belongs to is a param. await zavu.subAccounts.apiKeys.revoke("key_id_here", { id: "jx7abc123def456" }); ``` ```bash cURL theme={null} curl -X DELETE https://api.zavu.dev/v1/sub-accounts/jx7abc123def456/api-keys/key_id_here \ -H "Authorization: Bearer zv_live_xxx" ``` Revoked keys stop working immediately. ## Suspend a Sub-Account Set a sub-account's status to `inactive` to temporarily block all messaging without revoking API keys: ```bash theme={null} curl -X PATCH https://api.zavu.dev/v1/sub-accounts/jx7abc123def456 \ -H "Authorization: Bearer zv_live_xxx" \ -H "Content-Type: application/json" \ -d '{"status": "inactive"}' ``` Re-activate later by setting `status` back to `"active"`. ## Next Steps > [card] [Deletion & Deactivation](/guides/sub-accounts/deletion) > > Permanently deactivate sub-accounts > [card] [Sub-Accounts Concept](/concepts/sub-accounts) > > Understand the billing model and architecture # Sub-Accounts Overview Source: https://docs.zavu.dev/guides/sub-accounts/overview Create isolated messaging accounts for your customers with independent API keys Sub-accounts let you provision **isolated messaging environments** for each of your customers. Each sub-account gets its own API key, usage tracking, and optional spending cap — while all charges are billed to your team's balance. ## Quick Start **1. Create a sub-account** ```bash theme={null} curl -X POST https://api.zavu.dev/v1/sub-accounts \ -H "Authorization: Bearer zv_live_xxx" \ -H "Content-Type: application/json" \ -d '{ "name": "Client ABC", "externalId": "client_123", "creditLimit": 100000 }' ``` **2. Save the API key** The response includes the sub-account's API key. **The API returns it once and has no way to return it again** — store it when you receive it. If you lose it, open the sub-account in the dashboard and show the key from its **API Keys** screen, or create another key for it. ```json theme={null} { "subAccount": { "id": "jx7abc123def456", "name": "Client ABC", "status": "active", "totalSpent": 0, "creditLimit": 100000, "apiKey": "zv_live_sub_xxxxxxxxxxxx", "createdAt": "2025-01-15T10:00:00Z" } } ``` **3. Your customer sends messages** Your customer uses their API key to send messages: ```bash theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer zv_live_sub_xxxxxxxxxxxx" \ -d '{"to": "+14155551234", "text": "Hello from Client ABC!"}' ``` ## Guides > [card] [Creating Sub-Accounts](/guides/sub-accounts/creating) > > Provision sub-accounts with credit limits, external IDs, and metadata > [card] [Managing Sub-Accounts](/guides/sub-accounts/managing) > > Update settings, rotate API keys, and monitor spending > [card] [Deletion & Deactivation](/guides/sub-accounts/deletion) > > Deactivate sub-accounts and understand the 14-day deletion grace period ## Important Rules > [!WARNING] > > Each team can have **only one main account**. All additional projects must be created as sub-accounts. If you need a separate main account, create a new team. * Sub-accounts share the team's **balance** but are fully isolated * Each sub-account has its own API keys, senders, phone numbers, contacts, and usage tracking * Credit limits are optional — omit or set to `0` for unlimited spending * When a credit limit is reached, the sub-account's messages are blocked * Deactivating a sub-account revokes all its API keys immediately # Telegram Overview Source: https://docs.zavu.dev/guides/telegram/overview Bot messaging with powerful automation capabilities Telegram Bot API enables messaging through bots, offering powerful automation capabilities and rich message formatting. ## When to Use Telegram * **Customer support bots**: Automated responses and human handoff * **Notifications**: Real-time alerts and updates * **Communities**: Group messaging and channel broadcasts * **Automation**: Bot commands and workflows ## Basic Telegram Message ```typescript TypeScript theme={null} const result = await zavu.messages.send({ to: "123456789", // Telegram chat ID text: "Hello from Zavu!", channel: "telegram", }); ``` ```python Python theme={null} result = zavu.messages.send( to="123456789", # Telegram chat ID text="Hello from Zavu!", channel="telegram" ) ``` ```ruby Ruby theme={null} result = client.messages.send_( to: "123456789", # Telegram chat ID text: "Hello from Zavu!", channel: "telegram" ) ``` ```go Go theme={null} result, _ := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("123456789"), // Telegram chat ID Text: zavudev.String("Hello from Zavu!"), Channel: zavudev.String("telegram"), }) ``` ```php PHP theme={null} $result = $client->messages->send([ 'to' => '123456789', // Telegram chat ID 'text' => 'Hello from Zavu!', 'channel' => 'telegram', ]); ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "123456789", "text": "Hello from Zavu!", "channel": "telegram" }' ``` ## Key Differences from Other Channels | Feature | Telegram | SMS | WhatsApp | | - | - | - | - | | Recipient ID | Chat ID (numeric) | Phone number | Phone number | | Media support | Yes | Limited (MMS) | Yes | | Cost per message | Free\* | Per message | Per conversation | | Bots required | Yes | No | No | > [!NOTE] > > \*Telegram messaging is free. Messages count toward your plan's monthly message limits, with usage-based overage beyond them, as with other channels. ## Chat IDs vs Phone Numbers Unlike SMS and WhatsApp, Telegram uses numeric chat IDs rather than phone numbers: ``` Phone-based channels: +14155551234 Telegram: 123456789 ``` > [!WARNING] > > You cannot message Telegram users by phone number. Users must first interact with your bot to establish a chat ID. ## Getting Chat IDs Chat IDs are obtained when users interact with your bot: 1. User sends a message to your bot 2. Zavu receives the message via webhook 3. The `from` field contains the user's chat ID 4. Store and use this ID for future messages ## Message Types Telegram supports various message types: | Type | Use Case | | - | - | | Text | Simple messages with markdown support | | Photo | Images and graphics | | Document | Files and attachments | | Location | Share locations | | Contact | Share contact information | ## Quoted Replies To answer a specific message, set `content.replyToMessageId` to its Zavu ID, such as the `data.messageId` of a `message.inbound` webhook. Telegram shows the quoted message above your reply. ```typescript TypeScript theme={null} const result = await zavu.messages.send({ to: "123456789", channel: "telegram", text: "Got it, checking now.", content: { replyToMessageId: "msg_abc123" }, }); ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "123456789", "channel": "telegram", "text": "Got it, checking now.", "content": { "replyToMessageId": "msg_abc123" } }' ``` The quoted message must be in the same chat as `to` and must belong to the same bot you send from. Telegram numbers messages per chat, so a message from another chat or bot is refused with `400` and is never sent unquoted. A button tap is not a message: the inbound created when a contact taps an inline button (`data.isCallbackQuery: true` on its `message.inbound`) cannot be quoted, so quote the message that carried the button instead. When answering from a webhook, send to `data.telegramChatId`: `data.from` carries a `telegram:` prefix that `to` does not accept. Inbound Telegram messages do not report what the contact quoted yet. The `replyTo*` fields of `message.inbound` are filled in on WhatsApp only. ## Sending Templates on Telegram You can send pre-defined templates on Telegram by specifying `channel: "telegram"` and `messageType: "template"`: ```typescript TypeScript theme={null} const result = await zavu.messages.send({ to: "123456789", channel: "telegram", messageType: "template", content: { templateId: "tpl_abc123", templateVariables: { "1": "John", "2": "ORD-12345", }, }, }); ``` ```python Python theme={null} result = zavu.messages.send( to="123456789", channel="telegram", message_type="template", content={ "templateId": "tpl_abc123", "templateVariables": { "1": "John", "2": "ORD-12345" } } ) ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "123456789", "channel": "telegram", "messageType": "template", "content": { "templateId": "tpl_abc123", "templateVariables": { "1": "John", "2": "ORD-12345" } } }' ``` If the template has a `telegramBody`, it will be used instead of the default `body`. Contact variables like `{{contact.first_name}}` are automatically resolved from the recipient's contact metadata. > [!NOTE] > > Telegram templates do not require external approval like WhatsApp. You can create and use them immediately. ## Delivery Status Telegram provides delivery tracking: | Status | Description | | - | - | | `queued` | Message accepted by Zavu | | `sending` | Being sent to Telegram | | `delivered` | Delivered to Telegram servers | | `failed` | Delivery failed | > [!NOTE] > > Telegram doesn't provide read receipts like WhatsApp. You'll know the message was delivered, but not if it was read. ## Bot Setup Requirements Before sending Telegram messages, you need to: 1. Create a bot via [@BotFather](https://t.me/BotFather) 2. Get your bot token 3. Configure the bot in your Zavu sender settings See [Telegram Setup](/guides/telegram/setup) for detailed instructions. ## Common Errors | Error | Cause | Solution | | - | - | - | | `Bot token invalid` | Wrong or expired token | Regenerate token via BotFather | | `Chat not found` | Invalid chat ID | Verify the chat ID | | `Bot blocked by user` | User blocked your bot | Cannot message this user | | `Forbidden: bot was kicked` | Bot removed from group | Bot must be re-added | ## Limitations Before choosing Telegram as your messaging channel, consider these limitations: ### User Initiation Required > [!WARNING] > > Your bot cannot message users first. Users must send a message to your bot before you can reply or send notifications. This is a fundamental Telegram restriction. Unlike SMS or Email, you cannot "cold message" Telegram users. Build your flow so users initiate contact (e.g., via a "Message us on Telegram" button on your website). ### No Phone Number Messaging You cannot reach users by phone number. Telegram uses internal chat IDs: * **SMS/WhatsApp**: Send to `+14155551234` * **Telegram**: Send to `123456789` (chat ID) If you only have a user's phone number, you cannot message them on Telegram even if they have the app installed. ### No Read Receipts Telegram does not provide read receipts. You'll know: * Message was delivered to Telegram servers * Message failed to deliver You will **not** know: * If the user read the message * When the user read the message ### Rate Limits Telegram enforces rate limits on bot messages: | Scope | Limit | | - | - | | Same chat | \~1 message per second | | Different chats | \~30 messages per second | | Broadcast to many users | May require delays | > [!NOTE] > > Zavu handles rate limiting automatically, but large broadcasts may take longer to complete on Telegram compared to other channels. ### No Pre-Approved Templates Unlike WhatsApp, Telegram has no template approval system. This means: * **Pro**: Send any message content without approval * **Con**: No way to message users outside of an active conversation ### Media Limitations | Media Type | Max Size | | - | - | | Photos | 10 MB | | Documents | 50 MB | | Video | 50 MB | | Audio | 50 MB | ### Group Limitations * Bot must be added to the group by an admin * Bot can be kicked or restricted by group admins * In supergroups (>200 members), bot may need admin privileges to see all messages ### Bot Blocking Users can block your bot at any time. When blocked: * You cannot send messages to that user * You receive a `bot_blocked_by_user` error * There's no way to "unblock" programmatically ### No Fallback to Other Channels Unlike SMS/WhatsApp where numbers are portable, Telegram chat IDs are specific to Telegram. You cannot automatically fall back to SMS if Telegram fails, unless you have the user's phone number stored separately. ### Geographic Availability Telegram may be blocked or restricted in some countries (e.g., China, Iran at times). Consider your user base's location when choosing Telegram as a primary channel. ## Next Steps * [Telegram Setup](/guides/telegram/setup) - Configure your Telegram bot # Telegram Setup Source: https://docs.zavu.dev/guides/telegram/setup Create and configure your Telegram bot for messaging This guide walks you through creating a Telegram bot and connecting it to Zavu. ## Step 1: Create a Bot with BotFather 1. Open Telegram and search for [@BotFather](https://t.me/BotFather) 2. Start a conversation and send `/newbot` 3. Follow the prompts to name your bot 4. BotFather will give you a **bot token** like: ``` 123456789:ABCdefGHIjklMNOpqrsTUVwxyz ``` > [!WARNING] > > Keep your bot token secret. Anyone with this token can control your bot. ## Step 2: Configure in Zavu Dashboard 1. Go to **Senders** in your Zavu dashboard 2. Select your sender or create a new one 3. In the **Channels** tab, click **Add Telegram** 4. Paste your bot token 5. Click **Verify** to confirm the bot is valid 6. Click **Save** to complete setup ## Step 3: Set Up Webhook (Automatic) When you save your Telegram configuration, Zavu automatically: * Registers your webhook URL with Telegram * Starts receiving messages from your bot No manual webhook configuration is needed. ## Bot Commands (Optional) You can configure bot commands via BotFather: ``` /setcommands ``` Then send a list of commands: ``` start - Start the conversation help - Get help information ``` ## Testing Your Bot Once configured, test your bot: 1. Find your bot on Telegram by username 2. Send a message to the bot 3. Check your Zavu Inbox for the inbound message 4. Reply from the Inbox or use the API ```typescript TypeScript theme={null} const result = await zavu.messages.send({ to: "123456789", // Chat ID from inbound message text: "Thanks for reaching out!", channel: "telegram", }); ``` ```python Python theme={null} result = zavu.messages.send( to="123456789", # Chat ID from inbound message text="Thanks for reaching out!", channel="telegram" ) ``` ```ruby Ruby theme={null} result = client.messages.send_( to: "123456789", # Chat ID from inbound message text: "Thanks for reaching out!", channel: "telegram" ) ``` ```go Go theme={null} result, _ := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("123456789"), // Chat ID from inbound message Text: zavudev.String("Thanks for reaching out!"), Channel: zavudev.String("telegram"), }) ``` ```php PHP theme={null} $result = $client->messages->send([ 'to' => '123456789', // Chat ID from inbound message 'text' => 'Thanks for reaching out!', 'channel' => 'telegram', ]); ``` ## Getting Chat IDs Telegram chat IDs are obtained from inbound messages: 1. When a user messages your bot, Zavu receives the message 2. The `from` field contains the user's chat ID 3. Store this ID to send future messages > [!NOTE] > > Chat IDs are numeric (e.g., `123456789`). They are different from phone numbers and cannot be derived from them. ## Bot Privacy Mode By default, bots only receive messages that: * Are sent directly to the bot * Start with `/` (commands) in groups * Mention the bot in groups To receive all messages in groups, disable privacy mode via BotFather: ``` /setprivacy ``` ## Troubleshooting ### Bot token invalid * Regenerate the token via BotFather using `/token` * Make sure you copied the complete token ### Messages not arriving * Verify the bot is configured correctly in Zavu * Check that the webhook is registered (automatic on save) * Ensure the bot hasn't been blocked by the user ### Can't message user * Users must initiate contact first * Check if the user blocked the bot * Verify the chat ID is correct ## Next Steps * [Telegram Overview](/guides/telegram/overview) - Learn about Telegram capabilities * [Webhooks](/guides/receiving-messages/webhooks) - Configure webhook events # URL Verification Source: https://docs.zavu.dev/guides/url-verification/overview Pre-verify URLs before sending SMS or email messages containing links URL verification ensures that links in your SMS and email messages are safe and compliant. All URLs in SMS and email messages must be verified before the message can be sent. ## Why URL Verification? Carriers and email providers actively filter messages containing malicious or suspicious links. URL verification helps you: * **Prevent delivery failures** - Messages with unverified or blocked URLs won't be delivered * **Protect your reputation** - Avoid being flagged as a spam sender * **Ensure compliance** - Meet carrier requirements for SMS messaging * **Block malicious links** - Automatic detection of phishing and malware sites ## How It Works **1. Extract URLs** When you send a message, Zavu extracts all URLs from the content **2. Check Verification Status** Each URL is checked against your verified URLs list **3. Automatic Verification** New URLs are automatically verified against Google Web Risk API **4. Delivery Decision** Safe URLs are approved; malicious URLs block the message ## URL Verification Status | Status | Description | | - | - | | `pending` | URL submitted, awaiting verification | | `approved` | URL verified as safe | | `rejected` | URL rejected by security team | | `malicious` | URL flagged as potentially harmful | ## Pre-Verifying URLs Submit URLs for verification before using them in messages: ```typescript TypeScript theme={null} import Zavudev from '@zavudev/sdk'; const zavu = new Zavudev(); const result = await zavu.urls.submitForVerification({ url: "https://mystore.example.com/sale", }); console.log(`URL ID: ${result.url.id}`); console.log(`Status: ${result.url.status}`); // "approved" for safe URLs console.log(`Domain: ${result.url.domain}`); ``` ```ruby Ruby theme={null} require "zavudev" client = Zavudev::Client.new(api_key: ENV["ZAVUDEV_API_KEY"]) result = client.urls.submit_for_verification( url: "https://mystore.example.com/sale" ) puts "URL ID: #{result.url.id}" puts "Status: #{result.url.status}" # "approved" for safe URLs puts "Domain: #{result.url.domain}" ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/urls \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://mystore.example.com/sale" }' ``` ### Approved Response ```json theme={null} { "url": { "id": "url_abc123", "url": "https://mystore.example.com/sale", "domain": "mystore.example.com", "status": "approved", "approvalType": "auto_web_risk", "createdAt": "2024-01-15T10:30:00.000Z" } } ``` ## Blocked URLs ### URL Shorteners URL shorteners are **always blocked** because they hide the destination URL: ```bash theme={null} # These will be rejected bit.ly/abc123 t.co/xyz789 tinyurl.com/short ``` > [!WARNING] > > URL shorteners like bit.ly, t.co, goo.gl, tinyurl, and ow\.ly are not allowed. Always use the full destination URL in your messages. ### Malicious URLs URLs flagged by Google Web Risk as malicious are automatically blocked: ```json theme={null} { "code": "url_malicious", "message": "URL has been flagged as potentially harmful by security systems", "details": { "urls": ["https://malicious-site.example"], "threatTypes": ["MALWARE", "SOCIAL_ENGINEERING"], "warning": "fraud_prevention" } } ``` > [!NOTE] > > Attempts to send messages with malicious URLs are logged and may result in account review for repeated violations. ## Listing Verified URLs View all URLs that have been verified for your project: ```typescript TypeScript theme={null} const { items, nextCursor } = await zavu.urls.listVerified({ status: "approved", limit: 50, }); for (const url of items) { console.log(`${url.domain}: ${url.url} (${url.status})`); } ``` ```ruby Ruby theme={null} result = client.urls.list_verified( status: "approved", limit: 50 ) result.items.each do |url| puts "#{url.domain}: #{url.url} (#{url.status})" end ``` ```bash cURL theme={null} curl "https://api.zavu.dev/v1/urls?status=approved&limit=50" \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` ### Response ```json theme={null} { "items": [ { "id": "url_abc123", "url": "https://mystore.example.com/sale", "domain": "mystore.example.com", "status": "approved", "approvalType": "auto_web_risk", "createdAt": "2024-01-15T10:30:00.000Z" }, { "id": "url_def456", "url": "https://mystore.example.com/products", "domain": "mystore.example.com", "status": "approved", "approvalType": "auto_web_risk", "createdAt": "2024-01-15T10:25:00.000Z" } ], "nextCursor": null } ``` ## Getting URL Details ```typescript TypeScript theme={null} const url = await zavu.urls.retrieveDetails("url_abc123"); console.log(`URL: ${url.url}`); console.log(`Status: ${url.status}`); console.log(`Verified at: ${url.createdAt}`); ``` ```ruby Ruby theme={null} url = client.urls.retrieve_details("url_abc123") puts "URL: #{url.url}" puts "Status: #{url.status}" puts "Verified at: #{url.created_at}" ``` ```bash cURL theme={null} curl https://api.zavu.dev/v1/urls/{urlId} \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` ## Automatic Verification in Messages When you send a message with URLs, Zavu automatically extracts and verifies them: ```typescript theme={null} // URLs in message are automatically verified const message = await zavu.messages.send({ to: "+14155551234", text: "Check out our sale: https://mystore.example.com/sale", }); // If URL is not pre-verified, it will be checked automatically // If blocked, the message status will be "pending_url_verification" ``` ### Message Status: `pending_url_verification` If a message contains an unverified URL that requires manual review: ```json theme={null} { "id": "msg_abc123", "to": "+14155551234", "status": "pending_url_verification", "text": "Check out: https://new-domain.example.com/page" } ``` > [!TIP] > > Pre-verify URLs before sending campaigns to avoid delivery delays. Submit all your marketing URLs when setting up a new campaign. ## Best Practices > [card] Use Your Own Domain > > Register and verify your own domains for consistent delivery > [card] Avoid Shorteners > > Always use full URLs instead of shortening services > [card] Pre-Verify Campaign URLs > > Verify all URLs before launching broadcast campaigns > [card] Use HTTPS > > Always use HTTPS URLs for better security and trust ## Domain-Level Verification Once a URL is verified, subsequent URLs from the same domain are typically verified faster: ```typescript theme={null} // First URL from domain - full verification await zavu.urls.submitForVerification({ url: "https://mystore.example.com/sale" }); // Additional URLs from same domain - faster verification await zavu.urls.submitForVerification({ url: "https://mystore.example.com/products" }); await zavu.urls.submitForVerification({ url: "https://mystore.example.com/about" }); ``` ## Complete Example ```typescript theme={null} import Zavudev from '@zavudev/sdk'; const zavu = new Zavudev(); async function sendCampaignWithLinks() { const campaignUrls = [ "https://mystore.example.com/sale", "https://mystore.example.com/new-arrivals", ]; // 1. Pre-verify all campaign URLs console.log("Verifying campaign URLs..."); for (const url of campaignUrls) { try { const result = await zavu.urls.submitForVerification({ url }); console.log(`${url}: ${result.url.status}`); if (result.url.status !== "approved") { throw new Error(`URL not approved: ${url}`); } } catch (error) { console.error(`Failed to verify ${url}:`, error); return; } } // 2. Send messages with verified URLs console.log("\nSending campaign..."); const message = await zavu.messages.send({ to: "+14155551234", text: `Sale alert! Shop now: ${campaignUrls[0]}`, }); console.log(`Message sent: ${message.id} (${message.status})`); } sendCampaignWithLinks(); ``` ## Troubleshooting ### URL Rejected If your URL is rejected: 1. Check that it's not a URL shortener 2. Ensure the URL is accessible (not 404) 3. Verify the domain has valid SSL 4. Contact support if you believe it's a false positive ### Message Stuck in `pending_url_verification` 1. Submit the URL manually using `POST /v1/urls` 2. Wait for verification (usually seconds) 3. The message will be released once approved ### Malicious URL Detection If your legitimate URL is flagged as malicious: 1. Check [Google Safe Browsing](https://safebrowsing.google.com/) for your domain 2. Resolve any security issues on your site 3. Request a review from Google 4. Contact Zavu support with the resolution details # Configuration Source: https://docs.zavu.dev/guides/voice-agents/configuration Every voice setting: greeting, language, voicemail, transfer, and limits Voice behavior lives in the `voice` object on the sender's agent. Update it with `PATCH /v1/senders/{senderId}/agent`. All fields except `enabled` are optional and fall back to sensible defaults. ## Settings Reference | Field | Type | Default | Description | | - | - | - | - | | `enabled` | boolean | — | Whether the agent handles calls. When `false`, the number is not answered and outbound calls are rejected. | | `greeting` | string | — | Opening line the agent speaks when the call connects. If omitted, the agent waits for the caller to speak first. | | `language` | string | auto | BCP-47 code for speech recognition and synthesis (e.g. `en`, `es`, `pt-BR`). Auto-detected from the recipient when omitted. | | `ttsVoiceId` | string | follows `language` | Voice used to speak. List the valid ids with `npx zavudev agents voices`; a value that is not one of them is rejected. Defaults to a voice for the agent's `language`. | | `interruptible` | boolean | `true` | Whether the caller can interrupt the agent while it speaks (barge-in). | | `maxCallDurationMinutes` | integer | `15` | Hard cap on call length (1–120). The call ends automatically when reached. | | `maxIdleSeconds` | integer | `30` | How long the agent waits during silence before ending the call (5–300). | | `voicemailAction` | string | `hangup` | What to do when voicemail is detected on an outbound call: `hangup` or `leave_message`. | | `voicemailMessage` | string | — | Message spoken when `voicemailAction` is `leave_message`. Falls back to `greeting`. | | `transferPhoneNumber` | string | — | E.164 number the agent can transfer the call to. Enables a transfer tool the agent can use to reach a human. | ## Full Example ```typescript TypeScript theme={null} await zavu.senders.agent.update("sender_12345", { voice: { enabled: true, greeting: "Hi, thanks for calling Acme. How can I help you today?", language: "en", ttsVoiceId: "aura-2-thalia-en", interruptible: true, maxCallDurationMinutes: 20, maxIdleSeconds: 30, voicemailAction: "leave_message", voicemailMessage: "This is Acme. We missed you — please call us back at your convenience.", transferPhoneNumber: "+14155551234", }, }); ``` ```python Python theme={null} zavu.senders.agent.update( "sender_12345", voice={ "enabled": True, "greeting": "Hi, thanks for calling Acme. How can I help you today?", "language": "en", "ttsVoiceId": "aura-2-thalia-en", "interruptible": True, "maxCallDurationMinutes": 20, "maxIdleSeconds": 30, "voicemailAction": "leave_message", "voicemailMessage": "This is Acme. We missed you — please call us back at your convenience.", "transferPhoneNumber": "+14155551234", }, ) ``` ```bash cURL theme={null} curl -X PATCH https://api.zavu.dev/v1/senders/sender_12345/agent \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "voice": { "enabled": true, "greeting": "Hi, thanks for calling Acme. How can I help you today?", "language": "en", "ttsVoiceId": "aura-2-thalia-en", "interruptible": true, "maxCallDurationMinutes": 20, "maxIdleSeconds": 30, "voicemailAction": "leave_message", "voicemailMessage": "This is Acme. We missed you — please call us back at your convenience.", "transferPhoneNumber": "+14155551234" } }' ``` ## Greeting The greeting is the first thing the caller hears. Keep it short and specific — it sets the tone and tells the caller who they reached. * With a greeting, the agent speaks first. * Without one, the agent stays silent and lets the caller start. This suits inbound support lines where the caller already knows why they called. ## Voicemail Handling On outbound calls, Zavu detects when an answering machine or voicemail picks up instead of a person. | `voicemailAction` | Behavior | | - | - | | `hangup` (default) | End the call silently. Best when a voicemail is not useful. | | `leave_message` | Speak `voicemailMessage` (or the `greeting` if unset), then hang up. | > [!NOTE] > > Voicemail detection applies to outbound calls only. Inbound calls are always answered by the agent. ## Transferring to a Human Set `transferPhoneNumber` to give the agent a way to escalate. The agent receives a transfer tool it can invoke when a caller needs a person, forwarding the call to that number. Instruct the agent in its system prompt when to transfer — for example, on billing disputes or explicit requests for a human. ## Barge-in With `interruptible: true` (the default), the caller can talk over the agent and the agent stops to listen. This makes conversations feel natural. Set it to `false` for scripted messages that must play in full, such as compliance disclosures. ## Limits | Limit | Field | Range | Default | | - | - | - | - | | Maximum call duration | `maxCallDurationMinutes` | 1–120 | 15 | | Idle timeout | `maxIdleSeconds` | 5–300 | 30 | The duration limit is a hard stop that protects against runaway calls and cost. The idle timeout ends a call when neither side speaks for the configured period. ## Per-Call Overrides Two settings can be overridden for a single outbound call in `POST /v1/calls`, without changing the agent: | Body field | Overrides | | - | - | | `greeting` | The agent's configured `greeting` for this call only. | | `maxDurationMinutes` | The agent's `maxCallDurationMinutes` for this call only. | ```bash CLI theme={null} npx zavudev calls create \ --to "+56912345678" \ --sender sender_12345 \ --greeting "Hi, this is Acme calling to confirm your delivery for tomorrow." \ --max-minutes 5 ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/calls \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "+56912345678", "senderId": "sender_12345", "greeting": "Hi, this is Acme calling to confirm your delivery for tomorrow.", "maxDurationMinutes": 5 }' ``` ## Next Steps > [card] [Webhooks](/guides/voice-agents/webhooks) > > React to call lifecycle events > [card] [Agent Tools](/guides/ai-agents/tools) > > Let the agent take actions during a call # Voice Agents Overview Source: https://docs.zavu.dev/guides/voice-agents/overview Two-way phone calls answered by your AI agent Voice Agents turn your sender's AI agent into a phone agent. It answers inbound calls to your number and places outbound calls, holding a real conversation over the phone — not a recorded message. The same agent you already use for SMS and WhatsApp powers the call: its provider, model, system prompt, tools, and knowledge bases all carry over. You enable voice on the agent, and Zavu handles the call. > [!NOTE] > > Voice Agents is in Beta and available to every team. Placing and hanging up calls needs a live (`zv_live_`) API key. ## How It Works Each call runs through Zavu's managed voice pipeline: 1. **Speech recognition** transcribes the caller in real time. 2. **Your agent** processes the transcript with its configured model, tools, and knowledge bases — exactly as it does for text channels. 3. **Speech synthesis** speaks the agent's reply back to the caller. The pipeline supports **barge-in**: the caller can interrupt while the agent is speaking, and the agent stops to listen. The agent can also call your tools mid-conversation, transfer the call to a human, or end it. You never manage telephony, audio streaming, or transcription. You configure the agent and call the API. ## Inbound and Outbound | Direction | How it starts | | - | - | | **Inbound** | A caller dials a phone number assigned to a sender whose agent has voice enabled. Zavu answers automatically. | | **Outbound** | You call `POST /v1/calls`. Zavu dials the recipient and the agent takes over once answered. | Both directions use the same agent configuration and produce the same call record and transcript. ## Pricing Voice calls are billed per minute of connected time plus telephony, deducted from your prepaid balance. A short-duration estimate is reserved when a call is placed, and you are charged for the actual duration when it ends. A hard duration limit (`maxCallDurationMinutes`, default 15) caps every call. > [!NOTE] > > Keep a positive balance to place and receive calls. Inbound calls are answered only while the team balance is above zero. ## Calls and Transcripts Every call is recorded as a `call` resource with its status, duration, cost, and a turn-by-turn transcript. Retrieve a single call to read its full transcript, or list calls to review recent activity. Subscribe to the `call.*` webhook events to react to calls in real time — for example, to log completed conversations or trigger follow-ups. See [Webhooks](/guides/voice-agents/webhooks). ## Next Steps > [card] [Quickstart](/guides/voice-agents/quickstart) > > Enable voice and place your first call > [card] [Configuration](/guides/voice-agents/configuration) > > Greeting, language, voicemail, transfer, and limits > [card] [Webhooks](/guides/voice-agents/webhooks) > > Receive call lifecycle events > [card] [AI Agents](/guides/ai-agents/overview) > > Configure the agent that answers calls # Quickstart Source: https://docs.zavu.dev/guides/voice-agents/quickstart Enable voice on your agent and place your first call This guide takes you from an existing text agent to a working phone agent in three steps. ## Prerequisites * A **sender** with an AI agent configured. See [AI Agent Setup](/guides/ai-agents/setup). * A **phone number** assigned to that sender (required for inbound calls). See [Purchasing Phone Numbers](/guides/phone-numbers/purchasing). > [!NOTE] > > Voice calls run against live infrastructure and are not available with test-mode API keys. Use a `zv_live_` key. ## Step 1 — Enable Voice on the Agent Voice is a property of the agent. Patch the agent with a `voice` object to turn it on and set a greeting. ```typescript TypeScript theme={null} import Zavudev from "@zavudev/sdk"; const zavu = new Zavudev({ apiKey: process.env["ZAVUDEV_API_KEY"], }); await zavu.senders.agent.update("sender_12345", { voice: { enabled: true, greeting: "Hi, thanks for calling Acme. How can I help you today?", language: "en", interruptible: true, maxCallDurationMinutes: 15, }, }); ``` ```python Python theme={null} import os from zavudev import Zavudev zavu = Zavudev(api_key=os.environ.get("ZAVUDEV_API_KEY")) zavu.senders.agent.update( "sender_12345", voice={ "enabled": True, "greeting": "Hi, thanks for calling Acme. How can I help you today?", "language": "en", "interruptible": True, "maxCallDurationMinutes": 15, }, ) ``` ```bash cURL theme={null} curl -X PATCH https://api.zavu.dev/v1/senders/sender_12345/agent \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "voice": { "enabled": true, "greeting": "Hi, thanks for calling Acme. How can I help you today?", "language": "en", "interruptible": true, "maxCallDurationMinutes": 15 } }' ``` The agent keeps its existing provider, model, system prompt, tools, and knowledge bases. Voice only adds how it behaves on a call. ## Step 2 — Place an Outbound Call Call `POST /v1/calls` with the recipient's number. Zavu dials them, and the agent takes over when they answer. > [!NOTE] > > The voice endpoints are not generated in the language SDKs yet. Use the CLI or > call the API directly; both are shown throughout this guide. ```bash CLI theme={null} npx zavudev calls create --to "+56912345678" --sender sender_12345 ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/calls \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "+56912345678", "senderId": "sender_12345" }' ``` ```typescript TypeScript theme={null} // `@zavudev/sdk` has no `calls` resource yet, so call the endpoint directly. const res = await fetch("https://api.zavu.dev/v1/calls", { method: "POST", headers: { Authorization: `Bearer ${process.env.ZAVUDEV_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ to: "+56912345678", senderId: "sender_12345" }), }); const { call } = await res.json(); console.log("Call ID:", call.id); console.log("Status:", call.status); // "queued" or "ringing" ``` The response is `202` with the call in `queued` or `ringing` status. `senderId` is optional — the project's default sender is used when omitted. ## Step 3 — Receive Inbound Calls Inbound calls need no extra code. When someone dials a phone number assigned to the sender, Zavu answers with the voice agent automatically. To set it up: 1. Assign a phone number to the sender (see [Purchasing Phone Numbers](/guides/phone-numbers/purchasing)). 2. Confirm the sender's agent has `voice.enabled` set to `true` (Step 1). 3. Call the number. The agent answers with its greeting and holds the conversation. ## Read the Call and Transcript Retrieve a call to read its outcome and full transcript. The transcript is a list of turns in order. ```bash CLI theme={null} npx zavudev calls get call_abc123 npx zavudev calls list --status completed --limit 10 ``` ```bash cURL theme={null} curl https://api.zavu.dev/v1/calls/call_abc123 \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` ```typescript TypeScript theme={null} const res = await fetch("https://api.zavu.dev/v1/calls/call_abc123", { headers: { Authorization: `Bearer ${process.env.ZAVUDEV_API_KEY}` }, }); const { call } = await res.json(); console.log("Status:", call.status); console.log("Duration (s):", call.durationSeconds); for (const turn of call.transcript ?? []) { console.log(`${turn.role}: ${turn.text}`); } ``` To stop a call in progress, hang up: ```bash CLI theme={null} npx zavudev calls hangup call_abc123 ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/calls/call_abc123/hangup \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` ## Next Steps > [card] [Configuration](/guides/voice-agents/configuration) > > Voicemail handling, call transfer, and limits > [card] [Webhooks](/guides/voice-agents/webhooks) > > Get notified when calls start and end # Webhooks Source: https://docs.zavu.dev/guides/voice-agents/webhooks Receive call lifecycle events as they happen Voice Agents emit webhook events across a call's lifecycle. Subscribe to them on a sender webhook to log conversations, trigger follow-ups, or update your systems in real time. Configure the events the same way as any other sender webhook — see [Webhooks](/guides/receiving-messages/webhooks) for setup, and [Security](/guides/receiving-messages/security) to verify signatures. Voice events are signed exactly like `message.*` events, with the `X-Zavu-Signature` header. ## Events | Event | When it fires | `data.status` | | - | - | - | | `call.initiated` | An outbound call is dialing, or an inbound call is received. | `ringing` | | `call.answered` | The call is answered and the agent is connected. | `in_progress` | | `call.completed` | The call ended after a conversation. | `completed` | | `call.failed` | The call could not be completed (busy, no answer, canceled, or error). | `busy`, `no_answer`, `canceled`, or `failed` | Subscribe to `call.completed` and `call.failed` to handle outcomes. Add `call.initiated` and `call.answered` when you need live progress. ## Payload Structure All voice events share the standard webhook envelope. The `data` object carries the call fields. ```json theme={null} { "id": "evt_1705312200000_call123", "type": "call.completed", "timestamp": 1705312200000, "senderId": "snd_abc123", "projectId": "prj_xyz789", "data": { "callId": "call_abc123", "direction": "inbound", "from": "+14155551234", "to": "+13125559876", "status": "completed", "durationSeconds": 84, "endReason": "agent_ended", "transcriptAvailable": true } } ``` ### Data Fields | Field | Type | Description | | - | - | - | | `callId` | string | The call identifier. Use it to fetch the full call and transcript. | | `direction` | string | `inbound` or `outbound`. | | `from` | string | Caller number in E.164 format. | | `to` | string | Callee number in E.164 format. | | `status` | string | Call status for this event. | | `durationSeconds` | number \| null | Connected talk time in seconds. `null` before the call is answered. | | `endReason` | string \| null | Why the call ended (e.g. `agent_ended`, `max_duration`, `transfer`, `hangup`). Present on `call.completed` and `call.failed`. | | `transcriptAvailable` | boolean | Whether a transcript can be fetched for this call. | ## call.initiated Fired when an outbound call starts dialing or an inbound call arrives. ```json theme={null} { "id": "evt_1705312200000_init123", "type": "call.initiated", "timestamp": 1705312200000, "senderId": "snd_abc123", "projectId": "prj_xyz789", "data": { "callId": "call_abc123", "direction": "outbound", "from": "+13125559876", "to": "+14155551234", "status": "ringing", "durationSeconds": null, "endReason": null, "transcriptAvailable": false } } ``` ## call.answered Fired when the call connects and the agent begins the conversation. ```json theme={null} { "id": "evt_1705312210000_ans123", "type": "call.answered", "timestamp": 1705312210000, "senderId": "snd_abc123", "projectId": "prj_xyz789", "data": { "callId": "call_abc123", "direction": "outbound", "from": "+13125559876", "to": "+14155551234", "status": "in_progress", "durationSeconds": null, "endReason": null, "transcriptAvailable": false } } ``` ## call.completed Fired when a call ends after a conversation. Fetch the call to read the transcript. ```json theme={null} { "id": "evt_1705312300000_done123", "type": "call.completed", "timestamp": 1705312300000, "senderId": "snd_abc123", "projectId": "prj_xyz789", "data": { "callId": "call_abc123", "direction": "outbound", "from": "+13125559876", "to": "+14155551234", "status": "completed", "durationSeconds": 92, "endReason": "agent_ended", "transcriptAvailable": true } } ``` ## call.failed Fired when a call could not be completed. Check `status` and `endReason` for the cause. ```json theme={null} { "id": "evt_1705312260000_fail123", "type": "call.failed", "timestamp": 1705312260000, "senderId": "snd_abc123", "projectId": "prj_xyz789", "data": { "callId": "call_abc123", "direction": "outbound", "from": "+13125559876", "to": "+14155551234", "status": "no_answer", "durationSeconds": null, "endReason": "no_answer", "transcriptAvailable": false } } ``` ## Example Handler Verify the signature, acknowledge quickly, and process asynchronously — the same pattern as message webhooks. ```typescript TypeScript theme={null} async function handleVoiceEvent(event) { const { callId, status, direction, durationSeconds } = event.data; switch (event.type) { case "call.completed": { // Fetch the transcript for records or analysis. The voice endpoints are // not in the SDK yet, so call the API directly. const res = await fetch(`https://api.zavu.dev/v1/calls/${callId}`, { headers: { Authorization: `Bearer ${process.env.ZAVUDEV_API_KEY}` }, }); const { call } = await res.json(); await db.calls.save({ callId, direction, durationSeconds, transcript: call.transcript, }); break; } case "call.failed": await db.calls.markFailed(callId, event.data.endReason); break; } } ``` ```python Python theme={null} async def handle_voice_event(event): data = event["data"] call_id = data["callId"] if event["type"] == "call.completed": # Fetch the transcript for records or analysis. The voice endpoints are # not in the SDK yet, so call the API directly. res = httpx.get( f"https://api.zavu.dev/v1/calls/{call_id}", headers={"Authorization": f"Bearer {os.environ['ZAVUDEV_API_KEY']}"}, ) call = res.json()["call"] await db.calls.save( call_id=call_id, direction=data["direction"], duration_seconds=data["durationSeconds"], transcript=call["transcript"], ) elif event["type"] == "call.failed": await db.calls.mark_failed(call_id, data["endReason"]) ``` > [!TIP] > > Webhook deliveries may be retried, so make your handler idempotent — key it on the top-level event `id`. See [Event Types](/guides/receiving-messages/events#best-practices) for the pattern. ## Next Steps > [card] [Security](/guides/receiving-messages/security) > > Verify webhook signatures > [card] [Configuration](/guides/voice-agents/configuration) > > Tune how the agent behaves on calls # WhatsApp Business App Connection Source: https://docs.zavu.dev/guides/whatsapp/business-app Use your existing WhatsApp Business App phone number with Cloud API Connect your existing WhatsApp Business App to Zavu and use the same phone number for both personal conversations (via the app) and automated messaging (via Cloud API). ## What is WhatsApp Business App Connection? When you connect an existing WhatsApp Business App account, you enable **coexistence mode** - the ability to use a single phone number across two platforms: | Platform | Use Case | | - | - | | **WhatsApp Business App** | Personal conversations, 1:1 messaging from your phone | | **Cloud API (via Zavu)** | Automated messages, broadcasts, integrations | > [!NOTE] > > This is different from a standard Cloud API registration where the number can only be used via API. With coexistence, you keep using the WhatsApp Business App on your phone while also sending messages programmatically through Zavu. ## Benefits * **Keep using your phone**: Continue managing conversations directly from the WhatsApp Business App * **Add automation**: Send automated messages, templates, and broadcasts through Zavu * **Unified inbox**: All conversations appear in both the app and Zavu * **No number migration**: Use your existing business number without changes ## Limitations Before enabling coexistence, be aware of these limitations: | Limitation | Details | | - | - | | **Throughput** | Fixed at 80 messages per second (vs up to 1000 for dedicated Cloud API) | | **Countries** | Not available in Nigeria and South Africa | | **Companion devices** | Maximum 4 linked devices | | **Message features** | Edit and revoke messages disabled in the app | | **Disappearing messages** | Not supported | ## Connecting via Dashboard ### Step 1: Navigate to Senders 1. Log in to your [Zavu Dashboard](https://dashboard.zavu.dev) 2. Go to **Senders** in the navigation 3. Click **New Sender** or edit an existing sender ### Step 2: Connect WhatsApp 1. In the Channels section, click **Configure** on WhatsApp 2. Select **Connect WhatsApp Business App** > [!TIP] > > Choose this option if you're already using WhatsApp Business App and want to keep using it alongside Cloud API. ### Step 3: Complete Meta Signup 1. Click **Connect with Meta** to open the embedded signup 2. Log in with your Facebook account that has access to the WhatsApp Business App 3. Select your existing WhatsApp Business Account 4. Authorize Zavu to send messages on your behalf ### Step 4: Sync Message History (Optional) After connecting, you can optionally sync your existing conversation history: 1. On the success screen, click **Sync history** 2. This sends a request to the account owner to approve sharing 3. Once approved, historical messages will be imported to Zavu > [!WARNING] > > History sync requires approval from the WhatsApp Business App user. They will see a prompt in the app to share their conversation history. ## Connection Options Comparison When connecting WhatsApp to a sender, you have three options: > [card] Zavu Phone Number > > Use a phone number from your Zavu account. Best for dedicated business lines with high volume messaging. > [card] WhatsApp Business App > > Connect your existing WhatsApp Business App. Best for keeping personal messaging alongside automation. > [card] Own Phone Number > > Register any phone number you own. Best for using your own carrier number dedicated to Cloud API. ## Managing History Sync After connecting, you can manage synchronization from the sender settings: ### Sync Status | Status | Description | | - | - | | **Not synced** | History sync not requested | | **Syncing** | Sync in progress | | **Synced** | History imported successfully | | **Declined** | User declined to share history | ### Requesting Sync 1. Go to **Senders** → Select your sender 2. Navigate to the **Channels** tab 3. In the WhatsApp Business App Sync section, click **Sync** for: * **Message History**: Import existing conversations * **Contacts**: Import contact names from the app ## Sync via API You can also manage history and contacts sync programmatically using the REST API. ### Get Sync Status Check the current sync status for a sender: ```bash cURL theme={null} curl -X GET https://api.zavu.dev/v1/senders/{senderId}/whatsapp-sync \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` ```typescript TypeScript theme={null} const response = await fetch( `https://api.zavu.dev/v1/senders/${senderId}/whatsapp-sync`, { headers: { Authorization: `Bearer ${apiKey}` } } ); const { sync } = await response.json(); console.log(sync.history.status); // "not_requested" | "pending" | "syncing" | "completed" | "rejected" console.log(sync.contacts.canSync); // true if sync can be initiated ``` ```ruby Ruby theme={null} response = client.senders.get_whatsapp_sync(sender_id) puts response.sync.history.status puts response.sync.contacts.can_sync ``` ```go Go theme={null} response, err := client.Senders.GetWhatsAppSync(context.TODO(), senderID) fmt.Println(response.Sync.History.Status) fmt.Println(response.Sync.Contacts.CanSync) ``` ```php PHP theme={null} $response = $client->senders->getWhatsAppSync($senderId); echo $response->sync->history->status . "\n"; echo $response->sync->contacts->canSync . "\n"; ``` **Response:** ```json theme={null} { "sync": { "isCoexistence": true, "status": "active", "history": { "status": "completed", "canSync": false, "requestedAt": "2024-01-15T10:00:00Z", "completedAt": "2024-01-15T10:05:00Z" }, "contacts": { "status": "not_requested", "canSync": true, "requestedAt": null } } } ``` ### Start History Sync Initiate message history sync from the WhatsApp Business App: ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/senders/{senderId}/whatsapp-sync/history \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` ```typescript TypeScript theme={null} const response = await fetch( `https://api.zavu.dev/v1/senders/${senderId}/whatsapp-sync/history`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` } } ); // Returns 202 Accepted ``` ```ruby Ruby theme={null} client.senders.start_whatsapp_history_sync(sender_id) ``` ```go Go theme={null} response, err := client.Senders.StartWhatsAppHistorySync(context.TODO(), senderID) ``` ```php PHP theme={null} $client->senders->startWhatsAppHistorySync($senderId); ``` > [!NOTE] > > History sync requires approval from the WhatsApp Business App user. After calling this endpoint, the user will see a prompt in their app to approve sharing their conversation history. ### Start Contacts Sync Import contact names from the WhatsApp Business App: ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/senders/{senderId}/whatsapp-sync/contacts \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` ```typescript TypeScript theme={null} const response = await fetch( `https://api.zavu.dev/v1/senders/${senderId}/whatsapp-sync/contacts`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` } } ); // Returns 202 Accepted ``` ```ruby Ruby theme={null} client.senders.start_whatsapp_contacts_sync(sender_id) ``` ```go Go theme={null} response, err := client.Senders.StartWhatsAppContactsSync(context.TODO(), senderID) ``` ```php PHP theme={null} $client->senders->startWhatsAppContactsSync($senderId); ``` ### Error Responses | Error | Description | | - | - | | `Sender does not have a WhatsApp Business Account connected` | The sender has no WABA configured | | `Sync is only available for coexistence accounts` | The WABA is not in coexistence mode | | `WhatsApp account is not active` | The WABA status is not active | | `History sync is already in progress` | A sync request is already pending or running | | `Sync limit reached. Please wait 24 hours` | Meta rate limit exceeded | ## Receiving Messages With coexistence enabled, messages arrive in both places: 1. **WhatsApp Business App**: The user sees and can reply from their phone 2. **Zavu Webhooks**: Your application receives `message.inbound` events Messages sent from the WhatsApp Business App are also forwarded to your webhooks as `whatsapp.message_echo` events. ## Disconnecting To disconnect the WhatsApp Business App: > [!WARNING] > > Disconnection can only be initiated from the WhatsApp Business App, not from Zavu or the API. 1. Open WhatsApp Business App on your phone 2. Go to **Settings** → **Linked Devices** 3. Remove the Zavu connection When disconnected: * The sender status changes to `disconnected` in Zavu * You'll receive a `whatsapp.account_update` webhook event * The sender can no longer send WhatsApp messages ## Troubleshooting ### Can't see the "Connect WhatsApp Business App" option Make sure: * You're using WhatsApp Business App (not regular WhatsApp) * Your phone number is registered with WhatsApp Business * You have admin access to the associated Facebook Business account ### History sync stuck in "Syncing" * The account owner must approve the sync request in the WhatsApp Business App * Check the app for any pending prompts * Sync can take several minutes for large histories ### Messages not appearing in Zavu * Verify the sender status is `active` * Check webhook configuration is correct * Ensure the `message.inbound` event is subscribed ## Next Steps > [card] [Send Messages](/guides/whatsapp/overview) > > Start sending WhatsApp messages through your connected account > [card] [Configure Webhooks](/guides/receiving-messages/webhooks) > > Set up webhooks to receive inbound messages # Connect WhatsApp Source: https://docs.zavu.dev/guides/whatsapp/connect-whatsapp Connect a phone number to WhatsApp and switch it on Before you can send WhatsApp messages, you need to connect a phone number to WhatsApp through Meta's Business Platform. This guide walks you through the connection process. **How to connect Zavu to Meta with a Zavu phone number** ## Connection Options When you connect WhatsApp, you choose one of three routes: > [card] Zavu Phone Number > > **Recommended.** Use a phone number from your Zavu account. Best for dedicated business lines with high volume messaging. > [card] WhatsApp Business App > > Keep using the app on your phone while Zavu receives the same conversations (coexistence mode). The number must be on the WhatsApp Business app, not personal WhatsApp. > [card] Own Phone Number > > Move a number you control to the Cloud API. It must not be registered on WhatsApp: if it is, its account has to be deleted from the phone first. ## Connect a Zavu Phone Number (Recommended) This is the easiest way to get started with WhatsApp. Use a phone number you've purchased through Zavu. **1. Open Accounts** Log in to your [Zavu Dashboard](https://dashboard.zavu.dev), go to **Accounts**, and choose **Connect account → WhatsApp Business**. **2. Choose a Zavu number** Choose **Connect a Zavu phone number** (marked as Recommended). If you see the newer connect screen, the same option is called **A Zavu number**, and that screen lists what Meta will ask for before its window opens. **3. Complete Meta Business Signup** This opens the Meta Business Embed signup flow. Follow the wizard steps to connect your WhatsApp Business Account. **4. Enter your Zavu phone number** When the wizard asks for a phone number, enter your Zavu phone number. **5. Choose Call verification** When Meta asks how you want to verify the phone number, select **Call** (not SMS). > [!WARNING] > > You must select **Call** verification. Zavu phone numbers receive verification codes via voice call, not SMS. **6. Enter the verification code** Zavu will display the verification code from the incoming call. Copy this code and paste it into the Meta wizard to complete the setup. Zavu answers Meta's call for 40 minutes after you start the connection, and ends it after 90 seconds. Any other call to the number in that time is answered too; outside it, calls to a number without a voice agent are not answered. If Meta calls later, start again from **Accounts**. > [!WARNING] > > Connected is not the same as able to send. A connected account sends nothing until it is switched on. If your plan includes the connection, it switches on by itself as soon as Meta finishes registering the number. If it costs extra, you switch it on in **Accounts**, which shows the price before anything is charged. Until then WhatsApp is not listed in the sender's `channels` and every send on it is refused. ## Use Your Own Phone Number If you prefer to use your own phone number instead of a Zavu number: **1. Navigate to WhatsApp setup** Follow step 1 above to reach the connection options. **2. Select own phone number** Choose **Use my own phone number** (on the newer connect screen, **A number of mine that is not on WhatsApp**). **3. Complete verification** Enter your phone number in Meta's window. Meta sends a code by SMS or call to verify it, so keep the phone nearby. > [!WARNING] > > This route moves the number to the Cloud API, so the number must not be registered on WhatsApp. If it is on a phone, delete that WhatsApp account first. If it is registered with another provider, remove it in [WhatsApp Manager](https://business.facebook.com/wa/manage/phone-numbers/) and wait a few minutes before retrying. > > To keep the app on the phone instead, use **WhatsApp Business App** (coexistence). ## WhatsApp Business App (coexistence) Coexistence keeps the WhatsApp Business App working on the phone while Zavu receives the same conversations. The app stays; nothing moves. Choose **Connect WhatsApp Business App** (on the newer connect screen, **I already use WhatsApp Business App**). In Meta's window, pick the number already on the phone rather than registering a new one. Meta only offers this when all of these are true, and its dialog closes without saying which one is missing: * The number is on the **WhatsApp Business app**, version 2.24.17 or newer. Personal WhatsApp cannot be connected. * You sign in with your personal Facebook profile and have **admin access to the Facebook business** that owns the number. * The number is not from Nigeria or South Africa, where Meta does not offer coexistence yet. * The number's credit line is not still shared with another WhatsApp partner. If it is, follow Meta's switch-partner guide first. ## After Connection Once WhatsApp is connected **and switched on** (see the warning above): 1. **Send messages**: Start sending WhatsApp messages via the API or dashboard. Check that `whatsapp` is in the sender's `channels` first; until it is, every send is refused 2. **Configure webhooks**: Set up webhooks to receive inbound messages 3. **Create templates**: Set up message templates for outbound conversations outside the 24-hour window ## Troubleshooting ### Verification code not appearing * Make sure you selected **Call** verification, not SMS * Check that your Zavu phone number is active * Wait a few seconds for the call to come through ### Meta signup fails When Meta's dialog fails, the dashboard hands the failure to the Zavu assistant, which opens with the cause and the fix. The same causes, organised by where the signup gave up, are in [Troubleshooting WhatsApp Signup](/guides/whatsapp/troubleshooting-signup). * Sign in with your personal Facebook profile, not a Page * Accept every permission Meta lists * Try Chrome with extensions disabled ### WhatsApp not showing as connected * Refresh the dashboard page * Check **Accounts**: a number Meta is still registering shows **Verification pending**, and one that is connected but switched off appears under the **Deactivated** filter * Contact support if the issue persists ## Next Steps > [card] [Send WhatsApp Messages](/guides/whatsapp/overview) > > Learn about the different message types you can send > [card] [Create Templates](/guides/whatsapp/templates/overview) > > Set up templates for messages outside the 24-hour window # WhatsApp Error Codes Source: https://docs.zavu.dev/guides/whatsapp/error-codes Diagnose and resolve every WhatsApp delivery, template, and account error you can hit through Zavu When a WhatsApp message fails, Zavu surfaces the upstream error code in the message's `errorCode` and `errorMessage` fields, plus on the `message.failed` webhook. This page maps every error you can hit, explains *why* it happens, and tells you the exact action to take — in code, in the dashboard, or with your end user. > [!NOTE] > > All error codes on this page are the canonical codes returned by the WhatsApp Business Platform. Zavu passes them through unchanged so you can keep your existing handlers if you migrate from another stack. ## How Zavu Returns Errors Every failed WhatsApp message exposes the same shape, whether you read it from the API, the dashboard, or a webhook: ```json theme={null} { "id": "msg_01HX...", "status": "failed", "channel": "whatsapp", "errorCode": "131047", "errorMessage": "WhatsApp: 24-hour window closed — this contact has not messaged you in the last 24 hours, so free-form messages are refused. Send an approved template to open the conversation." } ``` * `errorCode` — Meta's code, as a string (see tables below). `Number(errorCode)` is safe, and is what the switch further down does. * `errorMessage` — a sentence for a human. For a few codes Zavu replaces Meta's own wording, which points the reader at the wrong problem; the code never changes, so branch on `errorCode` and display `errorMessage`. > [!NOTE] > > A rejection can arrive by either of two roads, and you have to handle both. Some are refused when you call the API. Others — `131047` among them — are accepted with a `202` and reported later on the `message.failed` webhook, because Meta answers the send before it evaluates delivery. A send that returned successfully is not a send that arrived. > [!TIP] > > Subscribe to the `message.failed` event on your webhook to react in real time instead of polling. See [Webhooks](/guides/receiving-messages/webhooks). ## Quick Triage Use this decision tree before diving into the tables: ```mermaid theme={null} flowchart TD A[Message failed] --> B{Error category} B -->|auth / integrity| C[Stop sending
Fix account first] B -->|rate_limit| D[Back off,
retry with delay] B -->|template| E[Fix template,
resubmit] B -->|delivery| F[Check 24h window
or contact validity] B -->|media| G[Re-encode or
resend asset] B -->|flow| H[Edit flow JSON
or republish] B -->|other| I[Inspect message,
retry if transient] ``` ## A. Authorization Errors These mean your WhatsApp Business Account (WABA) or the underlying app credentials aren't valid for the action you tried. They almost never resolve by retrying — you must reconnect the account. | Code | Name | Why it happens | What to do | | - | - | - | - | | `0` | AuthException | The platform can't authenticate the app behind the sender. | Reconnect the WhatsApp sender from **Senders → Connect WhatsApp**. See [Connect WhatsApp](/guides/whatsapp/connect-whatsapp). | | `3` | API Method | The endpoint exists but the calling app lacks the right scopes. | Reconnect with the full permission set; do not edit scopes manually. | | `10` | Permission Denied | The phone number isn't registered to your WABA, or messaging permission was revoked. | Verify the number appears under your sender and is in `connected` state. | | `190` | Access Token Expired | The long-lived token rotated or was invalidated. | Reconnect the sender — Zavu will rotate the token automatically. | | `200–299` | Permission Errors | A specific capability (templates, media, flows) was disabled. | Check the WABA status in the dashboard; if blocked, follow the [Integrity](#c-integrity-errors) flow. | > [!WARNING] > > If you see auth errors on *every* message, treat it as an outage on that sender. Pause broadcasts until the sender is `connected` again to avoid burning quality rating. ## B. Rate Limit & Throttling Errors WhatsApp enforces several independent limits: per-app calls, per-WABA throughput, per-recipient pacing, and account-level spam protection. Each has a different remediation window. | Code | Name | What it actually limits | What to do | | - | - | - | - | | `4` | Too Many Calls | App-level API call rate | Back off with exponential delay (start 1s, cap 60s). | | `80007` | Rate Limit Reached | Sender-level send rate | Retry after the `Retry-After` hint Zavu returns. | | `130429` | Throughput Limit | Messages per second on the WABA | Spread sends; rely on Zavu's [Broadcasts](/guides/broadcasts/overview) for automatic pacing. | | `131048` | Spam Rate Limit | Volume flagged as spammy by quality signals | Stop the campaign, inspect template quality, only resume once rating recovers. | | `131056` | Pair Rate Limit | Too many messages to the *same* recipient | Hold sends to that contact for \~30 min. Other recipients are unaffected. | | `133016` | Registration Limit | 10 phone register/deregister attempts in 72h | Wait out the 72h window. Don't script repeated reconnects. | > [!TIP] > > Zavu Broadcasts automatically respect WABA throughput limits. If you're hitting `130429` from your own send loop, switch to broadcasts. ### Retry strategy ```typescript theme={null} async function sendWithBackoff(payload, attempt = 0) { try { return await zavu.messages.send(payload); } catch (err) { const retriableCodes = [4, 80007, 130429]; if (retriableCodes.includes(Number(err.errorCode)) && attempt < 5) { const delay = Math.min(60_000, 1000 * 2 ** attempt); await new Promise((r) => setTimeout(r, delay)); return sendWithBackoff(payload, attempt + 1); } throw err; } } ``` ## C. Integrity Errors Account-level enforcement actions. The message wasn't sent because the platform restricted the sender or recipient. | Code | Name | Why it happens | What to do | | - | - | - | - | | `368` | Temporarily Blocked | A policy violation triggered a soft block on the WABA. | Open the WhatsApp Manager link in your sender details, resolve the flagged item, wait for unblock. | | `130497` | Country Restriction | The destination country has messaging restrictions for your business. | Use a different channel for that country. See [Sending Messages → SMS fallback](/guides/sending-messages/smart-routing). | | `131031` | Account Locked | Disabled for repeated policy or commerce-policy violations. | Contact Zavu support — recovery requires an appeal. | > [!WARNING] > > Don't keep retrying integrity errors. Each rejected send weighs against your quality rating. ## D. Template Errors Triggered when sending a template message. Most are fixable by editing the template; a few require creating a new one. | Code | Name | Cause | Fix | | - | - | - | - | | `100` | Invalid Parameter | The variable keys don't match the template's parameter format — e.g. a **named** template (`{{customer_name}}`) was sent positional keys, or a required variable (including a text-header variable) was omitted. | Pass values keyed to match the template body, and include every variable. See [How Variables Work](/guides/whatsapp/templates/sending#how-variables-work). | | `132000` | Parameter Count Mismatch | You passed fewer/more variables than the template defines. | Match `{{1}}, {{2}}, …` exactly. Inspect the template in the dashboard for the canonical count. | | `132001` | Template Doesn't Exist | Wrong name, wrong language, or template not approved. | Confirm `name` + `language` match an `approved` template. See [Sending Templates](/guides/whatsapp/templates/sending). | | `132005` | Translation Too Long | A localized version exceeds the character cap. | Shorten the body or header for that locale. | | `132007` | Format Policy Violation | Disallowed formatting, unsafe characters, or banned content. | Edit the template, resubmit. See [Template Approval](/guides/whatsapp/templates/approval). | | `132012` | Parameter Format Mismatch | A variable type doesn't match (e.g. text in a URL slot). | Send the correct type for each component. | | `132015` | Template Paused | Quality rating dropped to "low" — sending is paused. | Edit the template content to improve clarity and resubmit. | | `132016` | Template Disabled | Paused too many times; the template is permanently disabled. | Create a new template with improved copy. | | `132068` | Flow Blocked | The attached Flow violates policy. | Review the Flow, fix, republish. See [Flows](#g-whatsapp-flows-errors). | | `132069` | Flow Throttled | More than 10 flow messages sent within an hour. | Pace flow sends; investigate why the same flow triggers repeatedly. | > [!NOTE] > > A template that fails with `132015` or `132016` is a quality problem, not a code problem. Look at the template's quality rating in **Senders → Templates** before editing blindly. ## E. Delivery Errors The template/account was fine, but the message couldn't reach the recipient. | Code | Name | What happened | Recommended action | | - | - | - | - | | `131026` | Message Undeliverable | Recipient doesn't have WhatsApp, hasn't accepted ToS, or is on an outdated client version. | Fall back to SMS or email — Zavu's [Smart Routing](/guides/sending-messages/smart-routing) can do this automatically. | | `131047` | Re-engagement Required | More than 24h since the user last messaged you (no open session). | Send an approved template instead of a session message. | | `131049` | Per-User Marketing Limit | The platform throttled a marketing message to protect the recipient. | Reduce marketing frequency to that contact. Transactional/utility templates are unaffected. | | `131051` | Unsupported Message Type | The combination of message type and recipient device isn't supported. | Use a supported type (see [Message Types](/guides/whatsapp/overview#message-types)). | | `131062` | Recipient Requires Phone Number | The message was sent to a [BSUID](/guides/whatsapp/usernames) but the type requires a phone number (one-tap, zero-tap and copy-code authentication templates). | Send authentication templates to the contact's phone number. Recover it with a [contact info request](/guides/whatsapp/messages/request-contact-info) if you only have the BSUID. | ### Detect the 24-hour window ```typescript theme={null} const contact = await zavu.contacts.retrieve({ id: contactId }); const last = contact.whatsapp?.lastInboundAt; const within24h = last && Date.now() - new Date(last).getTime() < 24 * 60 * 60 * 1000; await zavu.messages.send({ to: contact.phone, channel: "whatsapp", ...(within24h ? { text: "Quick update on your order…" } : { template: { name: "order_update", language: "en", variables: ["#1234"] } }), }); ``` ## F. Media Errors Issues with the file attached to an inbound or outbound message. | Code | Name | Cause | Fix | | - | - | - | - | | `131052` | Media Download Error | Zavu couldn't fetch the media the user sent. | Ask the user to resend, or request the file via another channel. | | `131053` | Media Upload Error | Outbound media is wrong format, too large, or corrupted. | Verify against the [media specs](/guides/whatsapp/messages/image) for that message type. | > [!TIP] > > Common gotchas: PNGs over 5MB, MP4s without `h264` video + `aac` audio, and stickers that aren't 512×512 WebP. ## G. WhatsApp Flows Errors Errors specific to interactive Flows (multi-step forms inside WhatsApp). | Code | Name | Cause | Fix | | - | - | - | - | | `100` | Flow Management Error | Duplicate flow name, invalid ID/JSON, or unreachable endpoint URL. | Validate flow JSON locally; ensure your endpoint returns 200 to the health check. | | `139000` | Blocked by Integrity | The WABA can't publish flows right now. | Resolve any [Integrity](#c-integrity-errors) issues first. | | `139001` | Cannot Update Published Flow | Published flows are immutable. | Clone the flow, edit, publish a new version, deprecate the old one. | | `139002` | Incomplete Flow Details | Required fields, validation data, or endpoint missing. | Complete every required field before publishing. | | `139003` | Deprecation Error | Flow wasn't published or is already deprecated. | Check current status before calling deprecate. | | `139004` | Cannot Delete Published Flow | Published flows can't be deleted. | Deprecate instead. | | `139006` | Not Enough Metrics Data | Too few sessions to compute metrics. | Wait for more usage before requesting analytics. | ## H. Miscellaneous Errors Codes that don't fit cleanly elsewhere but you'll see eventually. | Code | Name | Cause | Fix | | - | - | - | - | | `131000` | Unknown Error | Unidentified upstream failure. | Retry once. If it persists, contact support with the message `id`. | | `131005` | Access Denied | Permission revoked between messages. | Reconnect the sender. | | `131008` | Missing Required Parameter | A required field is missing from the payload. | Check the [API reference](/api-reference) for that endpoint. | | `131009` | Invalid Parameter Value | A value is outside the supported set. | Validate enums and lengths before sending. | | `131016` | Service Unavailable | Temporary upstream outage. | Retry with backoff; check [status.zavu.dev](https://status.zavu.dev). | | `131021` | Sender Equals Recipient | The sender phone number matches the recipient. | Swap one of them — you can't message yourself. | | `131037` | Missing Display Name | The sender has no approved display name. | Set and approve a display name in **Senders → Profile**. See [Profile](/guides/whatsapp/profile). | | `131042` | Payment Issue | The WhatsApp Business Account has no usable payment method: none attached, credit limit reached, line of credit inactive, or currency/timezone unset. | Add or fix the payment method in **Meta Business Manager → Billing**, then resend. Meta bills WhatsApp conversations directly, so this is never fixed from Zavu. It is also the only signal that exists for this: Meta publishes no webhook for payment configuration, so a rejection is how you find out — including when a card is removed later. | | `131045` | Incorrect Certificate | Phone registration is in a bad state. | Reconnect the number from the dashboard. | | `131057` | Account in Maintenance | Temporary platform-side maintenance. | Wait and retry; this clears on its own. | ## Programmatic Error Handling Treat errors by category, not by code, to keep your code resilient as new codes appear: ```typescript theme={null} function classify(errorCode: number): "retry" | "fix-template" | "fix-account" | "fallback" | "drop" { if ([4, 80007, 130429, 131000, 131016, 131057].includes(errorCode)) return "retry"; if (errorCode >= 132000 && errorCode <= 132099) return "fix-template"; if ([0, 3, 10, 190, 368, 131005, 131031, 131037, 131042, 131045].includes(errorCode)) return "fix-account"; if ([131026, 131047, 131049, 131051].includes(errorCode)) return "fallback"; return "drop"; } // Webhooks are plain HTTPS POSTs to the URL configured on your sender, so this // lives in your own endpoint. Verify the signature first: see // /guides/receiving-messages/security app.post("/webhooks/zavu", async (req, res) => { const event = req.body; if (event.type !== "message.failed") return res.sendStatus(200); const action = classify(Number(event.data.errorCode)); switch (action) { case "retry": await enqueueRetry(event.data.messageId, { delay: 60_000 }); break; case "fallback": await sendViaSMS(event.data); break; case "fix-template": case "fix-account": await notifyOpsTeam(event.data); break; case "drop": await logForReview(event.data); break; } res.sendStatus(200); }); ``` ## Prevention Checklist > [card] Validate before send > > Verify phone format (E.164), template params, and 24h window state on your side. > [card] Watch quality rating > > A drop from `high` to `medium` is your first warning. Pause marketing before the platform does it for you. > [card] Use Smart Routing > > Let Zavu fall back to SMS/email automatically when WhatsApp returns delivery errors. > [card] Subscribe to webhooks > > `message.failed` and `template.status_changed` catch problems before users do. ## References The error codes documented here are defined by the WhatsApp Business Platform. For the authoritative, always-up-to-date list maintained by Meta: * [Cloud API error codes](https://developers.facebook.com/docs/whatsapp/cloud-api/support/error-codes/) — official reference * [Business messaging policy](https://www.whatsapp.com/legal/business-policy/) — content rules behind integrity and template rejections * [Messaging limits](https://developers.facebook.com/docs/whatsapp/cloud-api/overview#messaging-limits) — throughput tiers and quality rating This guide adapts those codes with Zavu-specific handling (error shape, webhooks, Smart Routing fallback). If Meta adds a new code that isn't listed here yet, Zavu still passes it through unchanged on `errorCode` — please [open an issue](https://github.com/zavudev) so we can add it. ## Next Steps > [card] [Smart Routing](/guides/sending-messages/smart-routing) > > Automatic channel fallback when WhatsApp delivery fails > [card] [Template Approval](/guides/whatsapp/templates/approval) > > Avoid 132xxx errors by getting templates right the first time > [card] [Webhooks](/guides/receiving-messages/webhooks) > > React to failures in real time > [card] [Rate Limiting](/concepts/rate-limiting) > > How Zavu paces sends to stay under WABA limits # Audio Messages Source: https://docs.zavu.dev/guides/whatsapp/messages/audio Send WhatsApp audio messages: MP3, OGG or M4A up to 16MB. Voice notes, podcasts and transcribed messages delivered in conversations. Send audio files for voice announcements, podcasts, or audio instructions. ## Send an Audio Message ```typescript TypeScript theme={null} const message = await zavu.messages.send({ to: "+14155551234", channel: "whatsapp", messageType: "audio", content: { mediaUrl: "https://example.com/announcement.mp3" } }); ``` ```python Python theme={null} message = zavu.messages.send( to="+14155551234", channel="whatsapp", message_type="audio", content={ "media_url": "https://example.com/announcement.mp3" } ) ``` ```ruby Ruby theme={null} message = client.messages.send_( to: "+14155551234", channel: "whatsapp", message_type: "audio", content: { media_url: "https://example.com/announcement.mp3" } ) ``` ```go Go theme={null} message, err := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("+14155551234"), Channel: zavudev.String("whatsapp"), MessageType: zavudev.String("audio"), Content: &zavudev.MessageContentParams{ MediaURL: zavudev.String("https://example.com/announcement.mp3"), }, }) ``` ```php PHP theme={null} $message = $client->messages->send([ 'to' => '+14155551234', 'channel' => 'whatsapp', 'messageType' => 'audio', 'content' => [ 'mediaUrl' => 'https://example.com/announcement.mp3', ], ]); ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer zv_live_xxx" \ -H "Content-Type: application/json" \ -d '{ "to": "+14155551234", "channel": "whatsapp", "messageType": "audio", "content": { "mediaUrl": "https://example.com/announcement.mp3" } }' ``` ## Specifications | Property | Requirement | | - | - | | Formats | AAC, MP3, MP4, AMR, OGG | | Max size | 16 MB | | Caption | Not supported | > [!NOTE] > > Audio messages do not support captions. If you need to add context, send a text message before or after the audio. ## URL Requirements Your audio URL must: * Be publicly accessible (no authentication) * Use HTTPS * Return the correct `Content-Type` header * Be available for at least 24 hours ## Use Cases * Voice announcements * Audio instructions * Podcast episodes * Voice notes * Music samples # Button Messages Source: https://docs.zavu.dev/guides/whatsapp/messages/buttons Send WhatsApp interactive button messages with up to 3 quick-reply options. Drive higher response rates than free-form messages. Button messages let users respond with a single tap, perfect for quick actions and surveys. ## Send Button Message ```typescript TypeScript theme={null} const message = await client.messages.send({ to: "+14155551234", text: "How would you rate our service?", channel: "whatsapp", messageType: "buttons", content: { buttons: [ { id: "good", title: "Good" }, { id: "average", title: "Average" }, { id: "poor", title: "Poor" } ] } }); ``` ```python Python theme={null} message = client.messages.send( to="+14155551234", text="How would you rate our service?", channel="whatsapp", message_type="buttons", content={ "buttons": [ {"id": "good", "title": "Good"}, {"id": "average", "title": "Average"}, {"id": "poor", "title": "Poor"} ] } ) ``` ```ruby Ruby theme={null} message = client.messages.send_( to: "+14155551234", text: "How would you rate our service?", channel: "whatsapp", message_type: "buttons", content: { buttons: [ { id: "good", title: "Good" }, { id: "average", title: "Average" }, { id: "poor", title: "Poor" } ] } ) ``` ```go Go theme={null} message, err := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("+14155551234"), Text: zavudev.String("How would you rate our service?"), Channel: zavudev.String("whatsapp"), MessageType: zavudev.String("buttons"), Content: &zavudev.MessageContentParams{ Buttons: []zavudev.ButtonParams{ {ID: zavudev.String("good"), Title: zavudev.String("Good")}, {ID: zavudev.String("average"), Title: zavudev.String("Average")}, {ID: zavudev.String("poor"), Title: zavudev.String("Poor")}, }, }, }) ``` ```php PHP theme={null} $message = $client->messages->send([ 'to' => '+14155551234', 'text' => 'How would you rate our service?', 'channel' => 'whatsapp', 'messageType' => 'buttons', 'content' => [ 'buttons' => [ ['id' => 'good', 'title' => 'Good'], ['id' => 'average', 'title' => 'Average'], ['id' => 'poor', 'title' => 'Poor'], ], ], ]); ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer zv_live_xxx" \ -H "Content-Type: application/json" \ -d '{ "to": "+14155551234", "text": "How would you rate our service?", "channel": "whatsapp", "messageType": "buttons", "content": { "buttons": [ { "id": "good", "title": "Good" }, { "id": "average", "title": "Average" }, { "id": "poor", "title": "Poor" } ] } }' ``` ## Specifications | Property | Requirement | | - | - | | Max buttons | 3 | | Button ID | Max 256 chars, unique per message | | Button title | Max 20 chars | | Body text (`text`) | Required, max 1024 chars | ## Handling Button Responses When a user taps a button, you receive a webhook: ```json theme={null} { "event": "message.received", "data": { "from": "+14155551234", "type": "button_reply", "button": { "id": "good", "title": "Good" } } } ``` > [!TIP] > > Use meaningful IDs like `confirm_order` or `cancel_order` to make webhook handling easier. ## Use Cases * Customer satisfaction surveys * Order confirmations (Yes/No) * Appointment scheduling options * Quick response options * A/B testing user preferences # Contact Messages Source: https://docs.zavu.dev/guides/whatsapp/messages/contact Send WhatsApp contact card messages: share contact info with name, phone numbers, emails and addresses. Useful for referrals and intros. Share contact cards with users so they can easily save contact information. ## Send a Contact ```typescript TypeScript theme={null} const message = await zavu.messages.send({ to: "+14155551234", channel: "whatsapp", messageType: "contact", content: { contacts: [ { name: "John Doe", phones: ["+1234567890"] } ] } }); ``` ```python Python theme={null} message = zavu.messages.send( to="+14155551234", channel="whatsapp", message_type="contact", content={ "contacts": [ { "name": "John Doe", "phones": ["+1234567890"] } ] } ) ``` ```ruby Ruby theme={null} message = client.messages.send_( to: "+14155551234", channel: "whatsapp", message_type: "contact", content: { contacts: [ { name: "John Doe", phones: ["+1234567890"] } ] } ) ``` ```go Go theme={null} message, err := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("+14155551234"), Channel: zavudev.String("whatsapp"), MessageType: zavudev.String("contact"), Content: &zavudev.MessageContentParams{ Contacts: []zavudev.ContactParams{ { Name: zavudev.String("John Doe"), Phones: []string{"+1234567890"}, }, }, }, }) ``` ```php PHP theme={null} $message = $client->messages->send([ 'to' => '+14155551234', 'channel' => 'whatsapp', 'messageType' => 'contact', 'content' => [ 'contacts' => [ [ 'name' => 'John Doe', 'phones' => ['+1234567890'], ], ], ], ]); ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer zv_live_xxx" \ -H "Content-Type: application/json" \ -d '{ "to": "+14155551234", "channel": "whatsapp", "messageType": "contact", "content": { "contacts": [ { "name": "John Doe", "phones": ["+1234567890"] } ] } }' ``` ## Multiple Phone Numbers Share a contact with multiple phone numbers: ```json theme={null} { "to": "+14155551234", "channel": "whatsapp", "messageType": "contact", "content": { "contacts": [ { "name": "Support Team", "phones": ["+1234567890", "+0987654321"] } ] } } ``` ## Multiple Contacts Share multiple contacts in a single message: ```json theme={null} { "to": "+14155551234", "channel": "whatsapp", "messageType": "contact", "content": { "contacts": [ { "name": "Sales Team", "phones": ["+1234567890"] }, { "name": "Support Team", "phones": ["+0987654321"] } ] } } ``` ## Specifications | Property | Requirement | | - | - | | name | Required | | phones | Required, array of phone numbers | ## Use Cases * Share support contact details * Send sales representative info * Provide emergency contacts * Share team member details # CTA URL Button Messages Source: https://docs.zavu.dev/guides/whatsapp/messages/cta-url Send a single call-to-action URL button via WhatsApp CTA URL button messages map a destination URL to a labeled button. Users never see the raw link — they tap the button and the URL opens in their default browser. Use this when you want to drive traffic from WhatsApp to a web experience (a booking page, a product page, a checkout flow) without exposing a long, opaque URL in the message body. ## Send a CTA URL Message ```typescript TypeScript theme={null} const message = await zavu.messages.send({ to: "+14155551234", channel: "whatsapp", messageType: "cta_url", text: "Tap the button below to see available dates.", content: { ctaDisplayText: "See Dates", ctaUrl: "https://example.com/schedule?ref=zavu", ctaHeaderType: "image", ctaHeaderMediaUrl: "https://example.com/banner.png", footerText: "Dates subject to change." } }); ``` ```python Python theme={null} message = zavu.messages.send( to="+14155551234", channel="whatsapp", message_type="cta_url", text="Tap the button below to see available dates.", content={ "cta_display_text": "See Dates", "cta_url": "https://example.com/schedule?ref=zavu", "cta_header_type": "image", "cta_header_media_url": "https://example.com/banner.png", "footer_text": "Dates subject to change." } ) ``` ```ruby Ruby theme={null} message = client.messages.send_( to: "+14155551234", channel: "whatsapp", message_type: "cta_url", text: "Tap the button below to see available dates.", content: { cta_display_text: "See Dates", cta_url: "https://example.com/schedule?ref=zavu", cta_header_type: "image", cta_header_media_url: "https://example.com/banner.png", footer_text: "Dates subject to change." } ) ``` ```go Go theme={null} message, err := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("+14155551234"), Channel: zavudev.String("whatsapp"), MessageType: zavudev.String("cta_url"), Text: zavudev.String("Tap the button below to see available dates."), Content: &zavudev.MessageContentParams{ CtaDisplayText: zavudev.String("See Dates"), CtaURL: zavudev.String("https://example.com/schedule?ref=zavu"), CtaHeaderType: zavudev.String("image"), CtaHeaderMediaURL: zavudev.String("https://example.com/banner.png"), FooterText: zavudev.String("Dates subject to change."), }, }) ``` ```php PHP theme={null} $message = $client->messages->send([ 'to' => '+14155551234', 'channel' => 'whatsapp', 'messageType' => 'cta_url', 'text' => 'Tap the button below to see available dates.', 'content' => [ 'ctaDisplayText' => 'See Dates', 'ctaUrl' => 'https://example.com/schedule?ref=zavu', 'ctaHeaderType' => 'image', 'ctaHeaderMediaUrl' => 'https://example.com/banner.png', 'footerText' => 'Dates subject to change.', ], ]); ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer zv_live_xxx" \ -H "Content-Type: application/json" \ -d '{ "to": "+14155551234", "channel": "whatsapp", "messageType": "cta_url", "text": "Tap the button below to see available dates.", "content": { "ctaDisplayText": "See Dates", "ctaUrl": "https://example.com/schedule?ref=zavu", "ctaHeaderType": "image", "ctaHeaderMediaUrl": "https://example.com/banner.png", "footerText": "Dates subject to change." } }' ``` ## Specifications | Property | Requirement | | - | - | | Body text (`text`) | Required, max 1024 chars | | `ctaDisplayText` | Required, max 20 chars | | `ctaUrl` | Required, valid URL (HTTPS required by WhatsApp in production) | | `ctaHeaderType` | Optional: `text`, `image`, `video`, `document` | | `ctaHeaderText` | Required if `ctaHeaderType` is `text`, max 60 chars | | `ctaHeaderMediaUrl` | Required if `ctaHeaderType` is `image`, `video` or `document`. HTTPS URL that returns valid media | | `footerText` | Optional, max 60 chars | > [!TIP] > > The sender must have a WhatsApp Business Account connected. Header media URLs are fetched by WhatsApp — they must be publicly reachable and return the declared content type (e.g. `image/png` for an `image` header). ## Header Variants ### Text header ```json theme={null} "content": { "ctaDisplayText": "Book now", "ctaUrl": "https://example.com/book", "ctaHeaderType": "text", "ctaHeaderText": "New workshop dates announced!" } ``` ### Image header ```json theme={null} "content": { "ctaDisplayText": "Shop now", "ctaUrl": "https://example.com/sale", "ctaHeaderType": "image", "ctaHeaderMediaUrl": "https://example.com/sale-banner.jpg" } ``` ### Video header ```json theme={null} "content": { "ctaDisplayText": "Watch more", "ctaUrl": "https://example.com/learn", "ctaHeaderType": "video", "ctaHeaderMediaUrl": "https://example.com/teaser.mp4" } ``` ### Document header ```json theme={null} "content": { "ctaDisplayText": "Open PDF", "ctaUrl": "https://example.com/brochure", "ctaHeaderType": "document", "ctaHeaderMediaUrl": "https://example.com/brochure.pdf" } ``` ## No Webhook on Tap > [!NOTE] > > Unlike reply [Buttons](/guides/whatsapp/messages/buttons) and [Lists](/guides/whatsapp/messages/list), **tapping a CTA URL button does not generate an inbound webhook**. The user is redirected directly to the destination URL inside their browser. > > To track clicks, append tracking parameters to `ctaUrl` (for example `?utm_source=whatsapp&clickID=abc123`) and read them on your landing page. ## 24-Hour Window CTA URL messages are free-form interactive messages. They can only be sent inside an open 24-hour conversation window — typically after the user has messaged you first. To initiate a conversation outside the window, send a [template message](/guides/whatsapp/templates/sending) first, then follow up with a CTA URL message once the user replies. ## Use Cases * Drive traffic to a booking or scheduling page * Link to a checkout or product page from a promotion * Share long campaign URLs without cluttering the message * Replace raw URLs in receipts or confirmations with a branded button * Direct users to a web form, survey, or support portal # Document Messages Source: https://docs.zavu.dev/guides/whatsapp/messages/document Send WhatsApp document messages: PDF, DOCX, XLSX up to 100MB. Include filenames and captions, with automatic CDN hosting. Send documents like PDFs, spreadsheets, or presentations to share invoices, contracts, or reports. ## Send a Document ```typescript TypeScript theme={null} const message = await zavu.messages.send({ to: "+14155551234", channel: "whatsapp", messageType: "document", content: { mediaUrl: "https://example.com/invoice.pdf", filename: "Invoice-12345.pdf" } }); ``` ```python Python theme={null} message = zavu.messages.send( to="+14155551234", channel="whatsapp", message_type="document", content={ "media_url": "https://example.com/invoice.pdf", "filename": "Invoice-12345.pdf" } ) ``` ```ruby Ruby theme={null} message = client.messages.send_( to: "+14155551234", channel: "whatsapp", message_type: "document", content: { media_url: "https://example.com/invoice.pdf", filename: "Invoice-12345.pdf" } ) ``` ```go Go theme={null} message, err := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("+14155551234"), Channel: zavudev.String("whatsapp"), MessageType: zavudev.String("document"), Content: &zavudev.MessageContentParams{ MediaURL: zavudev.String("https://example.com/invoice.pdf"), Filename: zavudev.String("Invoice-12345.pdf"), }, }) ``` ```php PHP theme={null} $message = $client->messages->send([ 'to' => '+14155551234', 'channel' => 'whatsapp', 'messageType' => 'document', 'content' => [ 'mediaUrl' => 'https://example.com/invoice.pdf', 'filename' => 'Invoice-12345.pdf', ], ]); ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer zv_live_xxx" \ -H "Content-Type: application/json" \ -d '{ "to": "+14155551234", "channel": "whatsapp", "messageType": "document", "content": { "mediaUrl": "https://example.com/invoice.pdf", "filename": "Invoice-12345.pdf" } }' ``` ## Document with Caption ```typescript TypeScript theme={null} const message = await zavu.messages.send({ to: "+14155551234", channel: "whatsapp", messageType: "document", text: "Your invoice for January 2024", content: { mediaUrl: "https://example.com/invoice.pdf", filename: "Invoice-12345.pdf" } }); ``` ```python Python theme={null} message = zavu.messages.send( to="+14155551234", channel="whatsapp", message_type="document", text="Your invoice for January 2024", content={ "media_url": "https://example.com/invoice.pdf", "filename": "Invoice-12345.pdf" } ) ``` ```ruby Ruby theme={null} message = client.messages.send_( to: "+14155551234", channel: "whatsapp", message_type: "document", text: "Your invoice for January 2024", content: { media_url: "https://example.com/invoice.pdf", filename: "Invoice-12345.pdf" } ) ``` ```go Go theme={null} message, err := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("+14155551234"), Channel: zavudev.String("whatsapp"), MessageType: zavudev.String("document"), Text: zavudev.String("Your invoice for January 2024"), Content: &zavudev.MessageContentParams{ MediaURL: zavudev.String("https://example.com/invoice.pdf"), Filename: zavudev.String("Invoice-12345.pdf"), }, }) ``` ```php PHP theme={null} $message = $client->messages->send([ 'to' => '+14155551234', 'channel' => 'whatsapp', 'messageType' => 'document', 'text' => 'Your invoice for January 2024', 'content' => [ 'mediaUrl' => 'https://example.com/invoice.pdf', 'filename' => 'Invoice-12345.pdf', ], ]); ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer zv_live_xxx" \ -H "Content-Type: application/json" \ -d '{ "to": "+14155551234", "channel": "whatsapp", "messageType": "document", "text": "Your invoice for January 2024", "content": { "mediaUrl": "https://example.com/invoice.pdf", "filename": "Invoice-12345.pdf" } }' ``` ## Specifications | Property | Requirement | | - | - | | Formats | PDF, DOC, DOCX, XLS, XLSX, PPT, PPTX, TXT | | Max size | 100 MB | | Filename | Optional but recommended | | Caption | Optional, max 1024 chars | > [!TIP] > > Always provide a descriptive `filename` so users know what they're downloading. ## URL Requirements Your document URL must: * Be publicly accessible (no authentication) * Use HTTPS * Return the correct `Content-Type` header * Be available for at least 24 hours ## Use Cases * Invoices and receipts * Contracts and agreements * Reports and presentations * User manuals * Shipping labels # Image Messages Source: https://docs.zavu.dev/guides/whatsapp/messages/image Send WhatsApp image messages: photos and graphics with optional captions. JPEG/PNG up to 5MB, automatic CDN delivery and template support. Send images with optional captions to share product photos, receipts, or visual content. ## Send an Image ```typescript TypeScript theme={null} const result = await zavu.messages.send({ to: "+14155551234", channel: "whatsapp", messageType: "image", content: { mediaUrl: "https://example.com/product.jpg", }, }); ``` ```python Python theme={null} result = zavu.messages.send( to="+14155551234", channel="whatsapp", message_type="image", content={ "media_url": "https://example.com/product.jpg" } ) ``` ```ruby Ruby theme={null} result = client.messages.send_( to: "+14155551234", channel: "whatsapp", message_type: "image", content: { media_url: "https://example.com/product.jpg" } ) ``` ```go Go theme={null} result, err := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("+14155551234"), Channel: zavudev.String("whatsapp"), MessageType: zavudev.String("image"), Content: &zavudev.MessageContentParams{ MediaURL: zavudev.String("https://example.com/product.jpg"), }, }) ``` ```php PHP theme={null} $result = $client->messages->send([ 'to' => '+14155551234', 'channel' => 'whatsapp', 'messageType' => 'image', 'content' => [ 'mediaUrl' => 'https://example.com/product.jpg', ], ]); ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "+14155551234", "channel": "whatsapp", "messageType": "image", "content": { "mediaUrl": "https://example.com/product.jpg" } }' ``` ## Image with Caption ```typescript TypeScript theme={null} const result = await zavu.messages.send({ to: "+14155551234", channel: "whatsapp", messageType: "image", text: "Check out our new product!", content: { mediaUrl: "https://example.com/product.jpg", }, }); ``` ```python Python theme={null} result = zavu.messages.send( to="+14155551234", channel="whatsapp", message_type="image", text="Check out our new product!", content={ "media_url": "https://example.com/product.jpg" } ) ``` ```ruby Ruby theme={null} result = client.messages.send_( to: "+14155551234", channel: "whatsapp", message_type: "image", text: "Check out our new product!", content: { media_url: "https://example.com/product.jpg" } ) ``` ```go Go theme={null} result, err := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("+14155551234"), Channel: zavudev.String("whatsapp"), MessageType: zavudev.String("image"), Text: zavudev.String("Check out our new product!"), Content: &zavudev.MessageContentParams{ MediaURL: zavudev.String("https://example.com/product.jpg"), }, }) ``` ```php PHP theme={null} $result = $client->messages->send([ 'to' => '+14155551234', 'channel' => 'whatsapp', 'messageType' => 'image', 'text' => 'Check out our new product!', 'content' => [ 'mediaUrl' => 'https://example.com/product.jpg', ], ]); ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "+14155551234", "channel": "whatsapp", "messageType": "image", "text": "Check out our new product!", "content": { "mediaUrl": "https://example.com/product.jpg" } }' ``` ## Specifications | Property | Requirement | | - | - | | Formats | JPEG, PNG | | Max size | 5 MB | | Caption | Optional, max 1024 chars | ## URL Requirements Your image URL must: * Be publicly accessible (no authentication) * Use HTTPS * Return the correct `Content-Type` header * Be available for at least 24 hours > [!WARNING] > > Private or expiring URLs will cause delivery failures. Use a CDN or cloud storage for reliable hosting. ## Use Cases * Product photos * Order receipts * QR codes * Promotional banners * Visual instructions # List Messages Source: https://docs.zavu.dev/guides/whatsapp/messages/list Send WhatsApp interactive list messages with up to 10 sections. Let users pick from menus, products or services in a single tap. List messages present a menu with sections and options, perfect for support menus or product selection. ## Send a List Message ```typescript TypeScript theme={null} const message = await zavu.messages.send({ to: "+14155551234", text: "Choose a support topic:", channel: "whatsapp", messageType: "list", content: { listButton: "View Options", sections: [ { title: "Account", rows: [ { id: "password", title: "Reset Password", description: "Change or reset your password" }, { id: "billing", title: "Billing Issue", description: "Questions about charges" } ] }, { title: "Orders", rows: [ { id: "track", title: "Track Order", description: "Check delivery status" }, { id: "return", title: "Return Item", description: "Start a return request" } ] } ] } }); ``` ```python Python theme={null} message = zavu.messages.send( to="+14155551234", text="Choose a support topic:", channel="whatsapp", message_type="list", content={ "list_button": "View Options", "sections": [ { "title": "Account", "rows": [ {"id": "password", "title": "Reset Password", "description": "Change or reset your password"}, {"id": "billing", "title": "Billing Issue", "description": "Questions about charges"} ] }, { "title": "Orders", "rows": [ {"id": "track", "title": "Track Order", "description": "Check delivery status"}, {"id": "return", "title": "Return Item", "description": "Start a return request"} ] } ] } ) ``` ```ruby Ruby theme={null} message = client.messages.send_( to: "+14155551234", text: "Choose a support topic:", channel: "whatsapp", message_type: "list", content: { list_button: "View Options", sections: [ { title: "Account", rows: [ { id: "password", title: "Reset Password", description: "Change or reset your password" }, { id: "billing", title: "Billing Issue", description: "Questions about charges" } ] }, { title: "Orders", rows: [ { id: "track", title: "Track Order", description: "Check delivery status" }, { id: "return", title: "Return Item", description: "Start a return request" } ] } ] } ) ``` ```go Go theme={null} message, err := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("+14155551234"), Text: zavudev.String("Choose a support topic:"), Channel: zavudev.String("whatsapp"), MessageType: zavudev.String("list"), Content: &zavudev.MessageContentParams{ ListButton: zavudev.String("View Options"), Sections: []zavudev.SectionParams{ { Title: zavudev.String("Account"), Rows: []zavudev.RowParams{ {ID: zavudev.String("password"), Title: zavudev.String("Reset Password"), Description: zavudev.String("Change or reset your password")}, {ID: zavudev.String("billing"), Title: zavudev.String("Billing Issue"), Description: zavudev.String("Questions about charges")}, }, }, { Title: zavudev.String("Orders"), Rows: []zavudev.RowParams{ {ID: zavudev.String("track"), Title: zavudev.String("Track Order"), Description: zavudev.String("Check delivery status")}, {ID: zavudev.String("return"), Title: zavudev.String("Return Item"), Description: zavudev.String("Start a return request")}, }, }, }, }, }) ``` ```php PHP theme={null} $message = $client->messages->send([ 'to' => '+14155551234', 'text' => 'Choose a support topic:', 'channel' => 'whatsapp', 'messageType' => 'list', 'content' => [ 'listButton' => 'View Options', 'sections' => [ [ 'title' => 'Account', 'rows' => [ ['id' => 'password', 'title' => 'Reset Password', 'description' => 'Change or reset your password'], ['id' => 'billing', 'title' => 'Billing Issue', 'description' => 'Questions about charges'], ], ], [ 'title' => 'Orders', 'rows' => [ ['id' => 'track', 'title' => 'Track Order', 'description' => 'Check delivery status'], ['id' => 'return', 'title' => 'Return Item', 'description' => 'Start a return request'], ], ], ], ], ]); ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer zv_live_xxx" \ -H "Content-Type: application/json" \ -d '{ "to": "+14155551234", "text": "Choose a support topic:", "channel": "whatsapp", "messageType": "list", "content": { "listButton": "View Options", "sections": [ { "title": "Account", "rows": [ { "id": "password", "title": "Reset Password", "description": "Change or reset your password" }, { "id": "billing", "title": "Billing Issue", "description": "Questions about charges" } ] }, { "title": "Orders", "rows": [ { "id": "track", "title": "Track Order", "description": "Check delivery status" }, { "id": "return", "title": "Return Item", "description": "Start a return request" } ] } ] } }' ``` ## Specifications | Property | Requirement | | - | - | | Max sections | 10 | | Max rows per section | 10 | | Section title | Max 24 chars | | Row ID | Max 200 chars, unique per message | | Row title | Max 24 chars | | Row description | Optional, max 72 chars | | List button (`listButton`) | Required, max 20 chars | | Body text (`text`) | Required | ## Handling List Responses When a user selects an option, you receive a webhook: ```json theme={null} { "event": "message.received", "data": { "from": "+14155551234", "type": "list_reply", "list": { "id": "track", "title": "Track Order", "description": "Check delivery status" } } } ``` ## Use Cases * Support menus * Product categories * Service selection * FAQ navigation * Multi-step workflows # Location Messages Source: https://docs.zavu.dev/guides/whatsapp/messages/location Send WhatsApp location messages with latitude, longitude, name and address. Useful for delivery confirmations, store locators and meetups. Share geographic locations with users to help them find your store, delivery points, or event venues. ## Send a Location ```typescript TypeScript theme={null} const message = await zavu.messages.send({ to: "+14155551234", channel: "whatsapp", messageType: "location", content: { latitude: 37.7749, longitude: -122.4194, locationName: "Zavu HQ", locationAddress: "123 Market Street, San Francisco, CA" } }); ``` ```python Python theme={null} message = zavu.messages.send( to="+14155551234", channel="whatsapp", message_type="location", content={ "latitude": 37.7749, "longitude": -122.4194, "location_name": "Zavu HQ", "location_address": "123 Market Street, San Francisco, CA" } ) ``` ```ruby Ruby theme={null} message = client.messages.send_( to: "+14155551234", channel: "whatsapp", message_type: "location", content: { latitude: 37.7749, longitude: -122.4194, location_name: "Zavu HQ", location_address: "123 Market Street, San Francisco, CA" } ) ``` ```go Go theme={null} message, err := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("+14155551234"), Channel: zavudev.String("whatsapp"), MessageType: zavudev.String("location"), Content: &zavudev.MessageContentParams{ Latitude: zavudev.Float64(37.7749), Longitude: zavudev.Float64(-122.4194), LocationName: zavudev.String("Zavu HQ"), LocationAddress: zavudev.String("123 Market Street, San Francisco, CA"), }, }) ``` ```php PHP theme={null} $message = $client->messages->send([ 'to' => '+14155551234', 'channel' => 'whatsapp', 'messageType' => 'location', 'content' => [ 'latitude' => 37.7749, 'longitude' => -122.4194, 'locationName' => 'Zavu HQ', 'locationAddress' => '123 Market Street, San Francisco, CA', ], ]); ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer zv_live_xxx" \ -H "Content-Type: application/json" \ -d '{ "to": "+14155551234", "channel": "whatsapp", "messageType": "location", "content": { "latitude": 37.7749, "longitude": -122.4194, "locationName": "Zavu HQ", "locationAddress": "123 Market Street, San Francisco, CA" } }' ``` ## Specifications | Property | Requirement | | - | - | | latitude | Required, -90 to 90 | | longitude | Required, -180 to 180 | | locationName | Optional, name displayed to user | | locationAddress | Optional, full address | > [!TIP] > > Include both `locationName` and `locationAddress` for the best user experience. Users can tap the location to open it in their maps app. ## Use Cases * Store locations * Delivery drop-off points * Event venues * Meeting locations * Service center addresses # Location Request Messages Source: https://docs.zavu.dev/guides/whatsapp/messages/location-request Ask a WhatsApp contact to share their location with a single tap. Useful for delivery addresses, field service dispatch and on-site check-ins. Send a message with a **Send location** button. When the contact taps it, WhatsApp opens the location picker and their answer arrives back in the conversation as a regular inbound `location` message. Use this instead of asking for an address in free text: you get exact coordinates rather than a string you have to geocode and correct. ## Send a Location Request The prompt goes in `text`. There is no `content` object — WhatsApp owns the button, so there is nothing to configure. ```typescript TypeScript theme={null} const message = await zavu.messages.send({ to: "+14155551234", channel: "whatsapp", messageType: "location_request", text: "To finish your order, share the delivery address." }); ``` ```python Python theme={null} message = zavu.messages.send( to="+14155551234", channel="whatsapp", message_type="location_request", text="To finish your order, share the delivery address." ) ``` ```ruby Ruby theme={null} message = client.messages.send_( to: "+14155551234", channel: "whatsapp", message_type: "location_request", text: "To finish your order, share the delivery address." ) ``` ```go Go theme={null} message, err := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("+14155551234"), Channel: zavudev.String("whatsapp"), MessageType: zavudev.String("location_request"), Text: zavudev.String("To finish your order, share the delivery address."), }) ``` ```php PHP theme={null} $message = $client->messages->send([ 'to' => '+14155551234', 'channel' => 'whatsapp', 'messageType' => 'location_request', 'text' => 'To finish your order, share the delivery address.', ]); ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer zv_live_xxx" \ -H "Content-Type: application/json" \ -d '{ "to": "+14155551234", "channel": "whatsapp", "messageType": "location_request", "text": "To finish your order, share the delivery address." }' ``` ## Receiving the Answer The reply is **not** a new message type. It arrives as a standard `message.inbound` webhook with `messageType: "location"`, and `content.replyToMessageId` set to the ID of the request you sent. That is how you correlate the two. ```json theme={null} { "type": "message.inbound", "data": { "messageId": "jd7x2k3m4n5p6q7r8s9t0", "from": "+14155551234", "channel": "whatsapp", "messageType": "location", "content": { "latitude": -33.4489, "longitude": -70.6693, "name": "Casa", "address": "Av. Providencia 1234, Santiago", "replyToMessageId": "jd6a1b2c3d4e5f6g7h8i9", "replyToMessageType": "location_request" } } } ``` > [!WARNING] > > `name` and `address` are optional — the contact only sends them if they pick a saved place instead of dropping a pin. Always read `latitude` and `longitude`; treat the other two as a bonus. Store the ID returned when you send the request, then match it against `content.replyToMessageId` on the inbound event: ```typescript theme={null} if ( event.type === "message.inbound" && event.data.messageType === "location" && event.data.content?.replyToMessageId === pendingRequestId ) { const { latitude, longitude } = event.data.content; await saveDeliveryCoordinates(orderId, latitude, longitude); } ``` ## Specifications | Property | Requirement | | - | - | | text | Required, max 1024 characters | | content | Not used | | channel | WhatsApp only | | Button label | Fixed by WhatsApp, not customizable | > [!NOTE] > > Like every non-template message, a location request needs an open [24-hour conversation window](/guides/whatsapp/overview). Outside the window, open the conversation with a template first. ## Use Cases * Confirming a delivery address before dispatch * Sending a technician to a customer's site * Finding the nearest branch or pickup point * Roadside assistance and field service * On-site check-ins # Quoted Replies Source: https://docs.zavu.dev/guides/whatsapp/messages/quoted-reply Reply to a specific WhatsApp message so it appears quoted above your answer. Set content.replyToMessageId to the ID of the message you are answering. A quoted reply shows the message you are answering above your own, the way a person does it in WhatsApp with a swipe. Use it when a conversation has several open questions and the contact needs to see which one you are answering. ## Send a Quoted Reply Set `content.replyToMessageId` to the Zavu ID of the message you are answering. For a message the contact sent you, that is the `data.messageId` of its `message.inbound` webhook. ```typescript TypeScript theme={null} const result = await zavu.messages.send({ to: "+14155551234", channel: "whatsapp", text: "Your order ships tomorrow.", content: { replyToMessageId: "msg_abc123", }, }); ``` ```python Python theme={null} result = zavu.messages.send( to="+14155551234", channel="whatsapp", text="Your order ships tomorrow.", content={"replyToMessageId": "msg_abc123"}, ) ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "+14155551234", "channel": "whatsapp", "text": "Your order ships tomorrow.", "content": { "replyToMessageId": "msg_abc123" } }' ``` The quote works on every WhatsApp message type except `reaction`, which already names the message it reacts to. An image, a document or a button message can quote too: add `replyToMessageId` next to the rest of its `content`. You can quote messages in both directions: one the contact sent you, or one you sent them. Either way it must belong to the conversation you are sending to, and the sender you send from must be the one that sent or received it. ## Replying from a Webhook ```typescript TypeScript theme={null} app.post("/webhooks/zavu", async (req, res) => { const event = req.body; if (event.type === "message.inbound" && event.data.channel === "whatsapp") { await zavu.messages.send({ to: event.data.from, channel: "whatsapp", text: await answer(event.data.text), content: { replyToMessageId: event.data.messageId }, // Answer from the sender that received the message. "Zavu-Sender": event.senderId, }); } res.sendStatus(200); }); ``` ## What Comes Back The stored message carries the full reply context, so `GET /v1/messages/{messageId}` shows what was quoted: ```json theme={null} { "content": { "replyToMessageId": "msg_abc123", "replyToProviderMessageId": "wamid.HBgL...ORIGINAL", "replyToFrom": "+14155551234", "replyToText": "When does my order ship?", "replyToMessageType": "text" } } ``` Only `replyToMessageId` is read from your request. The other `replyTo*` fields are filled in by Zavu. ## Errors A quote that cannot be delivered as a quote is refused. Zavu never sends it unquoted. | Status | Cause | Fix | | - | - | - | | `404` | The message is not in this project, or the ID is malformed | Use the `messageId` from the webhook or from `GET /v1/messages` | | `400` | The send is not going out on WhatsApp or Telegram | Set `channel` explicitly. With `auto`, a phone number can route to SMS | | `400` | The quoted message went out on another channel | Send on the channel of the quoted message | | `400` | The quoted message belongs to another conversation than `to` | Quote a message exchanged with this recipient | | `400` | The quoted message was sent or received by another sender | Pass that sender in the `Zavu-Sender` header | | `400` | The quoted message failed | Quote a message that was delivered or received | | `400` | The quoted message has no provider ID yet, because it is still queued | Wait until it is sent | | `400` | `messageType` is `reaction` | Remove `replyToMessageId` | Quoted replies also work on [Telegram](/guides/telegram/overview#quoted-replies). # Reaction Messages Source: https://docs.zavu.dev/guides/whatsapp/messages/reaction Send WhatsApp reaction emojis on existing messages. Acknowledge, agree or react to user messages without sending a full reply. React to messages with emoji to acknowledge receipt or add context without sending a full reply. ## React to a Message The easiest way to react is using the reactions endpoint: ```typescript TypeScript theme={null} const reaction = await zavu.messages.react("msg_abc123", { emoji: "👍" }); ``` ```python Python theme={null} reaction = zavu.messages.react("msg_abc123", emoji="👍") ``` ```ruby Ruby theme={null} reaction = client.messages.react("msg_abc123", emoji: "👍") ``` ```go Go theme={null} reaction, err := client.Messages.React(context.TODO(), "msg_abc123", zavudev.ReactionParams{ Emoji: zavudev.String("👍"), }) ``` ```php PHP theme={null} $reaction = $client->messages->react('msg_abc123', [ 'emoji' => '👍', ]); ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/messages/msg_abc123/reactions \ -H "Authorization: Bearer zv_live_xxx" \ -H "Content-Type: application/json" \ -d '{ "emoji": "👍" }' ``` ## React Using Message Type You can also send a reaction as a message by specifying the WhatsApp message ID: ```typescript TypeScript theme={null} const message = await zavu.messages.send({ to: "+14155551234", channel: "whatsapp", messageType: "reaction", content: { emoji: "👍", reactToMessageId: "wamid.abc123..." } }); ``` ```python Python theme={null} message = zavu.messages.send( to="+14155551234", channel="whatsapp", message_type="reaction", content={ "emoji": "👍", "react_to_message_id": "wamid.abc123..." } ) ``` ```ruby Ruby theme={null} message = client.messages.send_( to: "+14155551234", channel: "whatsapp", message_type: "reaction", content: { emoji: "👍", react_to_message_id: "wamid.abc123..." } ) ``` ```go Go theme={null} message, err := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("+14155551234"), Channel: zavudev.String("whatsapp"), MessageType: zavudev.String("reaction"), Content: &zavudev.MessageContentParams{ Emoji: zavudev.String("👍"), ReactToMessageID: zavudev.String("wamid.abc123..."), }, }) ``` ```php PHP theme={null} $message = $client->messages->send([ 'to' => '+14155551234', 'channel' => 'whatsapp', 'messageType' => 'reaction', 'content' => [ 'emoji' => '👍', 'reactToMessageId' => 'wamid.abc123...', ], ]); ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer zv_live_xxx" \ -H "Content-Type: application/json" \ -d '{ "to": "+14155551234", "channel": "whatsapp", "messageType": "reaction", "content": { "emoji": "👍", "reactToMessageId": "wamid.abc123..." } }' ``` ## Remove a Reaction Send an empty string as the emoji to remove a reaction: ```json theme={null} { "to": "+14155551234", "channel": "whatsapp", "messageType": "reaction", "content": { "emoji": "", "reactToMessageId": "wamid.abc123..." } } ``` ## Specifications | Property | Requirement | | - | - | | emoji | Required, single emoji (empty string to remove) | | reactToMessageId | Required, the WhatsApp message ID to react to | > [!NOTE] > > You can only react to messages within the 24-hour conversation window. ## Use Cases * Acknowledge message receipt * Quick approval/rejection indicators * Add emotional context * Non-intrusive confirmations # Contact Info Request Messages Source: https://docs.zavu.dev/guides/whatsapp/messages/request-contact-info Ask a WhatsApp contact to share their phone number with a single tap. Essential for contacts who adopted a username and hid their number. Send a message with a **Share Contact Info** button. When the contact taps it and confirms, their phone number arrives back in the conversation as a regular inbound `contact` message. This matters most for contacts who adopted a [WhatsApp username](/guides/whatsapp/usernames) and hid their phone number: it is the official way to recover a number you can call, text or verify. ## Send a Contact Info Request The prompt goes in `text`. There is no `content` object: WhatsApp owns the button, so there is nothing to configure. ```typescript TypeScript theme={null} const message = await zavu.messages.send({ to: "US.13491208655302741918", channel: "whatsapp", messageType: "request_contact_info", text: "Share your phone number so our team can call you back." }); ``` ```python Python theme={null} message = zavu.messages.send( to="US.13491208655302741918", channel="whatsapp", message_type="request_contact_info", text="Share your phone number so our team can call you back." ) ``` ```ruby Ruby theme={null} message = client.messages.send_( to: "US.13491208655302741918", channel: "whatsapp", message_type: "request_contact_info", text: "Share your phone number so our team can call you back." ) ``` ```go Go theme={null} message, err := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("US.13491208655302741918"), Channel: zavudev.String("whatsapp"), MessageType: zavudev.String("request_contact_info"), Text: zavudev.String("Share your phone number so our team can call you back."), }) ``` ```php PHP theme={null} $message = $client->messages->send([ 'to' => 'US.13491208655302741918', 'channel' => 'whatsapp', 'messageType' => 'request_contact_info', 'text' => 'Share your phone number so our team can call you back.', ]); ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer zv_live_xxx" \ -H "Content-Type: application/json" \ -d '{ "to": "US.13491208655302741918", "channel": "whatsapp", "messageType": "request_contact_info", "text": "Share your phone number so our team can call you back." }' ``` `to` can be a phone number or a BSUID. Asking a phone-number contact is valid too, for example to confirm the best callback number. ## Receiving the Answer The reply arrives as a standard `message.inbound` webhook with `messageType: "contact"`, carrying the shared number in `content.contacts`: ```json theme={null} { "type": "message.inbound", "data": { "messageId": "msg_xyz789", "from": "US.13491208655302741918", "channel": "whatsapp", "messageType": "contact", "content": { "contacts": [ { "name": "Sheena", "phones": ["+16505551234"] } ] } } } ``` > [!NOTE] > > When the sender was only known by BSUID, Zavu automatically links the shared phone number to that contact. You do not need to update the contact yourself: from that point on, the phone number and the BSUID resolve to the same contact and conversation. Your sender's webhook receives a [`contact.identity_linked`](/guides/receiving-messages/events) event with `reason: "contact_shared"`. Sharing is not the only way a number gets linked; see [Usernames & BSUIDs](/guides/whatsapp/usernames#from-can-change-for-the-same-person). ## In Templates Outside the 24-hour window, use a template with a `request_contact_info` button instead. See [Template Button Types](/guides/whatsapp/templates/overview#button-types). ## Specifications | Property | Requirement | | - | - | | text | Required, max 1024 characters | | content | Not used | | channel | WhatsApp only | | Button label | Fixed by WhatsApp, not customizable | > [!NOTE] > > Like every non-template message, a contact info request needs an open [24-hour conversation window](/guides/whatsapp/overview). Outside the window, open the conversation with a template first. ## Use Cases * Recovering a callable number for a [username contact](/guides/whatsapp/usernames) known only by BSUID * Confirming the best callback number before a support call * Collecting a number for SMS fallback or delivery notifications * Identity checks that require a verified phone number # Sticker Messages Source: https://docs.zavu.dev/guides/whatsapp/messages/sticker Send WhatsApp sticker messages: animated and static WEBP stickers under 100KB. Engaging visual replies in customer conversations. Send static or animated stickers for fun, engaging communication. ## Send a Sticker ```typescript TypeScript theme={null} const message = await zavu.messages.send({ to: "+14155551234", channel: "whatsapp", messageType: "sticker", content: { mediaUrl: "https://example.com/sticker.webp" } }); ``` ```python Python theme={null} message = zavu.messages.send( to="+14155551234", channel="whatsapp", message_type="sticker", content={ "media_url": "https://example.com/sticker.webp" } ) ``` ```ruby Ruby theme={null} message = client.messages.send_( to: "+14155551234", channel: "whatsapp", message_type: "sticker", content: { media_url: "https://example.com/sticker.webp" } ) ``` ```go Go theme={null} message, err := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("+14155551234"), Channel: zavudev.String("whatsapp"), MessageType: zavudev.String("sticker"), Content: &zavudev.MessageContentParams{ MediaURL: zavudev.String("https://example.com/sticker.webp"), }, }) ``` ```php PHP theme={null} $message = $client->messages->send([ 'to' => '+14155551234', 'channel' => 'whatsapp', 'messageType' => 'sticker', 'content' => [ 'mediaUrl' => 'https://example.com/sticker.webp', ], ]); ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer zv_live_xxx" \ -H "Content-Type: application/json" \ -d '{ "to": "+14155551234", "channel": "whatsapp", "messageType": "sticker", "content": { "mediaUrl": "https://example.com/sticker.webp" } }' ``` ## Specifications | Property | Requirement | | - | - | | Format | WebP | | Dimensions | 512x512 pixels | | Static size | Max 100 KB | | Animated size | Max 500 KB | > [!NOTE] > > Stickers must be exactly 512x512 pixels in WebP format. Other dimensions or formats will be rejected. ## Creating Stickers To create WhatsApp-compatible stickers: 1. Design at 512x512 pixels 2. Export as WebP format 3. Keep file size under limits (100KB static, 500KB animated) 4. Host on a public HTTPS URL ## Use Cases * Brand engagement * Celebration messages * Thank you responses * Fun customer interactions # Text Messages Source: https://docs.zavu.dev/guides/whatsapp/messages/text Send WhatsApp text messages: plain text up to 4,096 characters with optional URL previews. The simplest way to start conversations. Text messages are the simplest WhatsApp message type. They support basic formatting and links. ## Send a Text Message ```typescript TypeScript theme={null} const result = await zavu.messages.send({ to: "+14155551234", text: "Hello from Zavu!", channel: "whatsapp", }); ``` ```python Python theme={null} result = zavu.messages.send( to="+14155551234", text="Hello from Zavu!", channel="whatsapp" ) ``` ```ruby Ruby theme={null} result = client.messages.send_( to: "+14155551234", text: "Hello from Zavu!", channel: "whatsapp" ) ``` ```go Go theme={null} result, err := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("+14155551234"), Text: zavudev.String("Hello from Zavu!"), Channel: zavudev.String("whatsapp"), }) ``` ```php PHP theme={null} $result = $client->messages->send([ 'to' => '+14155551234', 'text' => 'Hello from Zavu!', 'channel' => 'whatsapp', ]); ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "+14155551234", "text": "Hello from Zavu!", "channel": "whatsapp" }' ``` ## Text Formatting WhatsApp supports basic formatting in text messages: | Format | Syntax | Example | | - | - | - | | Bold | `*text*` | *Hello* | | Italic | `_text_` | *Hello* | | Strikethrough | `~text~` | ~~Hello~~ | | Monospace | `` `text` `` | `Hello` | ### Example with Formatting ```json theme={null} { "to": "+14155551234", "text": "Your order *#12345* is _confirmed_.\n\nTotal: `$99.99`", "channel": "whatsapp" } ``` ## Specifications | Property | Requirement | | - | - | | Max length | 4096 characters | | Links | Auto-detected and clickable | | Emoji | Fully supported | | Line breaks | Use `\n` | ## Use Cases * Order confirmations * Appointment reminders * Account notifications * Two-factor authentication codes * General customer communication # Typing Indicators Source: https://docs.zavu.dev/guides/whatsapp/messages/typing-indicator Mark an inbound WhatsApp message as read and show a typing indicator while you prepare a response. Improves UX when replies take more than a couple of seconds. When a customer messages your business and the reply needs a moment to prepare — an LLM agent, a database lookup, an external API call — you can show the same "typing…" dots WhatsApp users see when a friend is replying. The same call also marks the inbound message as read (blue checks). ## Show a Typing Indicator Call the typing endpoint with the inbound message ID you want to acknowledge: ```typescript TypeScript theme={null} await zavu.messages.showTyping("msg_abc123"); ``` ```python Python theme={null} zavu.messages.show_typing("msg_abc123") ``` ```ruby Ruby theme={null} client.messages.show_typing("msg_abc123") ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/messages/msg_abc123/typing \ -H "Authorization: Bearer zv_live_xxx" ``` The indicator is automatically dismissed when: * You send a reply, OR * 25 seconds elapse — whichever comes first. ## When to Use Show the indicator only when a response really is on the way. The typical pattern is to call it as the first thing your webhook handler does for an inbound message that will trigger slow work: ```typescript theme={null} // In your webhook handler for `message.inbound` await zavu.messages.showTyping(event.data.messageId); const reply = await yourLLM.generate(event.data.text); await zavu.messages.send({ to: event.data.from, channel: "whatsapp", text: reply, }); ``` This is especially valuable for [AI Agents](/guides/ai-agents) — without it, the user sees nothing happen for several seconds after they send their message. ## Specifications | Property | Requirement | | - | - | | Message channel | Must be `whatsapp` | | Message direction | Must be `inbound` | | Provider message ID | Must be present (set automatically on received messages) | > [!NOTE] > > Do not call this endpoint speculatively — only when you are about to respond. Showing typing dots without ever replying degrades trust. > [!NOTE] > > The 24-hour conversation window applies. Typing indicators on messages outside that window will fail. ## Related * [Reactions](/guides/whatsapp/messages/reaction) — acknowledge a message without sending a full reply * [AI Agents](/guides/ai-agents) — agents that benefit most from typing indicators # Video Messages Source: https://docs.zavu.dev/guides/whatsapp/messages/video Send WhatsApp video messages: MP4 clips with captions up to 16MB. Auto-thumbnails, fast CDN delivery and template support included. Send videos for tutorials, product demos, or promotional content. ## Send a Video ```typescript TypeScript theme={null} const message = await zavu.messages.send({ to: "+14155551234", channel: "whatsapp", messageType: "video", content: { mediaUrl: "https://example.com/tutorial.mp4" } }); ``` ```python Python theme={null} message = zavu.messages.send( to="+14155551234", channel="whatsapp", message_type="video", content={ "media_url": "https://example.com/tutorial.mp4" } ) ``` ```ruby Ruby theme={null} message = client.messages.send_( to: "+14155551234", channel: "whatsapp", message_type: "video", content: { media_url: "https://example.com/tutorial.mp4" } ) ``` ```go Go theme={null} message, err := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("+14155551234"), Channel: zavudev.String("whatsapp"), MessageType: zavudev.String("video"), Content: &zavudev.MessageContentParams{ MediaURL: zavudev.String("https://example.com/tutorial.mp4"), }, }) ``` ```php PHP theme={null} $message = $client->messages->send([ 'to' => '+14155551234', 'channel' => 'whatsapp', 'messageType' => 'video', 'content' => [ 'mediaUrl' => 'https://example.com/tutorial.mp4', ], ]); ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer zv_live_xxx" \ -H "Content-Type: application/json" \ -d '{ "to": "+14155551234", "channel": "whatsapp", "messageType": "video", "content": { "mediaUrl": "https://example.com/tutorial.mp4" } }' ``` ## Video with Caption ```typescript TypeScript theme={null} const message = await zavu.messages.send({ to: "+14155551234", channel: "whatsapp", messageType: "video", text: "How to use our app", content: { mediaUrl: "https://example.com/tutorial.mp4" } }); ``` ```python Python theme={null} message = zavu.messages.send( to="+14155551234", channel="whatsapp", message_type="video", text="How to use our app", content={ "media_url": "https://example.com/tutorial.mp4" } ) ``` ```ruby Ruby theme={null} message = client.messages.send_( to: "+14155551234", channel: "whatsapp", message_type: "video", text: "How to use our app", content: { media_url: "https://example.com/tutorial.mp4" } ) ``` ```go Go theme={null} message, err := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("+14155551234"), Channel: zavudev.String("whatsapp"), MessageType: zavudev.String("video"), Text: zavudev.String("How to use our app"), Content: &zavudev.MessageContentParams{ MediaURL: zavudev.String("https://example.com/tutorial.mp4"), }, }) ``` ```php PHP theme={null} $message = $client->messages->send([ 'to' => '+14155551234', 'channel' => 'whatsapp', 'messageType' => 'video', 'text' => 'How to use our app', 'content' => [ 'mediaUrl' => 'https://example.com/tutorial.mp4', ], ]); ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer zv_live_xxx" \ -H "Content-Type: application/json" \ -d '{ "to": "+14155551234", "channel": "whatsapp", "messageType": "video", "text": "How to use our app", "content": { "mediaUrl": "https://example.com/tutorial.mp4" } }' ``` ## Specifications | Property | Requirement | | - | - | | Formats | MP4, 3GPP | | Max size | 16 MB | | Codec | H.264 video, AAC audio | | Caption | Optional, max 1024 chars | ## URL Requirements Your video URL must: * Be publicly accessible (no authentication) * Use HTTPS * Return the correct `Content-Type` header * Be available for at least 24 hours > [!TIP] > > Compress videos before uploading to ensure faster delivery and better user experience on mobile networks. ## Use Cases * Product demonstrations * Tutorial videos * Promotional content * Event highlights * Customer testimonials # WhatsApp Overview Source: https://docs.zavu.dev/guides/whatsapp/overview WhatsApp messaging overview: rich media, templates, buttons, lists and the 24-hour conversation window. WhatsApp Business API enables high-engagement messaging with rich features like images, documents, interactive buttons, and more. ## When to Use WhatsApp * **Customer support**: Rich media and quick replies * **Marketing campaigns**: Higher open rates than SMS (90%+ vs 20%) * **Order updates**: Detailed information with images and tracking * **International messaging**: Often cheaper than SMS ## Basic WhatsApp Message ```typescript TypeScript theme={null} const result = await zavu.messages.send({ to: "+14155551234", text: "Hello from Zavu!", channel: "whatsapp", }); ``` ```python Python theme={null} result = zavu.messages.send( to="+14155551234", text="Hello from Zavu!", channel="whatsapp" ) ``` ```ruby Ruby theme={null} result = client.messages.send_( to: "+14155551234", text: "Hello from Zavu!", channel: "whatsapp" ) ``` ```go Go theme={null} result, err := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("+14155551234"), Text: zavudev.String("Hello from Zavu!"), Channel: zavudev.String("whatsapp"), }) ``` ```php PHP theme={null} $result = $client->messages->send([ 'to' => '+14155551234', 'text' => 'Hello from Zavu!', 'channel' => 'whatsapp', ]); ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "+14155551234", "text": "Hello from Zavu!", "channel": "whatsapp" }' ``` ## The 24-Hour Conversation Window WhatsApp enforces a conversation window that determines what types of messages you can send: ``` User sends message → 24-hour window opens → Send any message type ↓ Window expires → Template messages only ``` | Message Type | Within 24h | After 24h | | - | - | - | | Text (session) | Yes | No | | Media (session) | Yes | No | | Interactive (session) | Yes | No | | Template (pre-approved) | Yes | Yes | > [!WARNING] > > Sending a session message outside the 24-hour window will fail. Use templates for messages outside the window. ## Message Types WhatsApp supports rich message formats beyond plain text: ### Session Messages (within 24h window) | Type | Use Case | Guide | | - | - | - | | Text | Simple messages | [Text](/guides/whatsapp/messages/text) | | Image | Product photos, receipts | [Image](/guides/whatsapp/messages/image) | | Video | Tutorials, promotions | [Video](/guides/whatsapp/messages/video) | | Audio | Voice notes, announcements | [Audio](/guides/whatsapp/messages/audio) | | Document | PDFs, invoices | [Document](/guides/whatsapp/messages/document) | | Sticker | Engagement, branding | [Sticker](/guides/whatsapp/messages/sticker) | | Location | Store location, delivery | [Location](/guides/whatsapp/messages/location) | | Contact | Share contact info | [Contact](/guides/whatsapp/messages/contact) | | Buttons | Quick actions | [Buttons](/guides/whatsapp/messages/buttons) | | List | Multiple options | [List](/guides/whatsapp/messages/list) | | Reaction | Acknowledge messages | [Reaction](/guides/whatsapp/messages/reaction) | | Typing indicator | Show typing dots while preparing a reply | [Typing](/guides/whatsapp/messages/typing-indicator) | ### Template Messages (anytime) Pre-approved messages that can be sent outside the 24-hour window. See [Templates Guide](/guides/whatsapp/templates/sending). ## Delivery Status WhatsApp provides detailed delivery tracking: | Status | Description | | - | - | | `queued` | Message accepted by Zavu | | `sent` | Sent to WhatsApp servers | | `delivered` | Delivered to user's device | | `read` | User opened the message | | `failed` | Delivery failed | > [!TIP] > > WhatsApp is the only channel that provides "read" receipts, useful for tracking engagement. ## Quality Rating Meta tracks your messaging quality. Maintain good standing by: 1. **Getting opt-in consent** before messaging 2. **Respecting user preferences** - stop messaging users who don't respond 3. **Sending relevant content** - avoid spam-like messages 4. **Responding promptly** - engage within the 24-hour window > [!WARNING] > > Low quality ratings can result in reduced messaging limits or account suspension. ## Common Errors | Error | Cause | Solution | | - | - | - | | `Outside 24h window` | Session expired | Use a template message | | `Template not approved` | Template pending/rejected | Wait for approval or fix issues | | `Invalid phone format` | Wrong number format | Use E.164 format (+country code) | | `User not on WhatsApp` | Recipient doesn't have WhatsApp | Fall back to SMS | | `Rate limited` | Too many messages too fast | Slow down sending rate | ## Next Steps * [Text Messages](/guides/whatsapp/messages/text) - Basic text with formatting * [Image Messages](/guides/whatsapp/messages/image) - Send photos and images * [Buttons](/guides/whatsapp/messages/buttons) - Interactive quick replies * [Templates](/guides/whatsapp/templates/sending) - Pre-approved messages for any time # WhatsApp Business Profile Source: https://docs.zavu.dev/guides/whatsapp/profile Manage your WhatsApp Business profile information visible to customers Your WhatsApp Business Profile contains important information about your business that customers see when they interact with you. This includes your business description, address, email, website, and profile picture. ## Profile Fields | Field | Max Length | Description | | - | - | - | | **About** | 139 chars | Short tagline or status | | **Description** | 512 chars | Detailed business description | | **Address** | 256 chars | Physical business address | | **Email** | - | Contact email address | | **Websites** | 2 URLs | Your business websites | | **Category** | - | Business vertical/industry | | **Profile Picture** | 5MB | Square image (JPEG or PNG) | ## Business Categories Choose the category that best describes your business: | Value | Display Name | | - | - | | `AUTO` | Automotive | | `BEAUTY` | Beauty, Spa and Salon | | `APPAREL` | Clothing and Apparel | | `EDU` | Education | | `ENTERTAIN` | Entertainment | | `EVENT_PLAN` | Event Planning and Service | | `FINANCE` | Finance and Banking | | `GROCERY` | Grocery and Supermarket | | `GOVT` | Government and Public Service | | `HOTEL` | Hotel and Lodging | | `HEALTH` | Medical and Health | | `NONPROFIT` | Non-profit | | `PROF_SERVICES` | Professional Services | | `RETAIL` | Shopping and Retail | | `TRAVEL` | Travel and Transportation | | `RESTAURANT` | Restaurant | | `OTHER` | Other | ## Managing via Dashboard The easiest way to manage your WhatsApp Business Profile is through the Zavu dashboard. ### Prerequisites * A sender with WhatsApp Business Account connected * The sender must be active (not in draft status) ### Steps 1. Navigate to **Senders** in your dashboard 2. Select the sender with WhatsApp configured 3. Click on the **Profile** tab (only visible for WhatsApp-enabled senders) 4. Update your profile information: * Upload or change your profile picture * Edit your about text, description, and address * Add your contact email * Add up to 2 website URLs * Select your business category 5. Click **Save Changes** > [!NOTE] > > Changes to your WhatsApp Business Profile are applied immediately and visible to customers. ## Managing via API You can also manage your WhatsApp Business Profile programmatically using the Zavu API. ### Get Current Profile Retrieve the current profile information for a sender: ```typescript TypeScript theme={null} import Zavudev from '@zavudev/sdk'; const zavu = new Zavu({ apiKey: process.env.ZAVUDEV_API_KEY }); const response = await zavu.senders.getProfile('snd_abc123'); console.log('About:', response.profile.about); console.log('Description:', response.profile.description); console.log('Address:', response.profile.address); console.log('Email:', response.profile.email); console.log('Websites:', response.profile.websites); console.log('Category:', response.profile.vertical); console.log('Picture URL:', response.profile.profilePictureUrl); ``` ```python Python theme={null} from zavudev import Zavu client = Zavu(api_key=os.environ["ZAVUDEV_API_KEY"]) response = client.senders.get_profile("snd_abc123") print("About:", response.profile.about) print("Description:", response.profile.description) print("Address:", response.profile.address) print("Email:", response.profile.email) print("Websites:", response.profile.websites) print("Category:", response.profile.vertical) print("Picture URL:", response.profile.profile_picture_url) ``` ```ruby Ruby theme={null} response = client.senders.get_profile("snd_abc123") puts "About: #{response.profile.about}" puts "Description: #{response.profile.description}" puts "Address: #{response.profile.address}" puts "Email: #{response.profile.email}" puts "Websites: #{response.profile.websites}" puts "Category: #{response.profile.vertical}" puts "Picture URL: #{response.profile.profile_picture_url}" ``` ```go Go theme={null} response, err := client.Senders.GetProfile(context.TODO(), "snd_abc123") fmt.Println("About:", response.Profile.About) fmt.Println("Description:", response.Profile.Description) fmt.Println("Address:", response.Profile.Address) fmt.Println("Email:", response.Profile.Email) fmt.Println("Websites:", response.Profile.Websites) fmt.Println("Category:", response.Profile.Vertical) fmt.Println("Picture URL:", response.Profile.ProfilePictureURL) ``` ```php PHP theme={null} $response = $client->senders->getProfile('snd_abc123'); echo 'About: ' . $response->profile->about . "\n"; echo 'Description: ' . $response->profile->description . "\n"; echo 'Address: ' . $response->profile->address . "\n"; echo 'Email: ' . $response->profile->email . "\n"; echo 'Category: ' . $response->profile->vertical . "\n"; echo 'Picture URL: ' . $response->profile->profilePictureUrl . "\n"; ``` ```bash cURL theme={null} curl -X GET "https://api.zavu.dev/v1/senders/snd_abc123/profile" \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` ### Update Profile Update one or more profile fields: ```typescript TypeScript theme={null} import Zavudev from '@zavudev/sdk'; const zavu = new Zavu({ apiKey: process.env.ZAVUDEV_API_KEY }); const response = await zavu.senders.updateProfile('snd_abc123', { about: 'Your trusted online store', description: 'We offer the best products at competitive prices with fast shipping and excellent customer service.', address: '123 Main Street, San Francisco, CA 94102', email: 'support@example.com', websites: ['https://example.com', 'https://shop.example.com'], vertical: 'RETAIL', }); console.log('Profile updated successfully'); ``` ```python Python theme={null} from zavudev import Zavu client = Zavu(api_key=os.environ["ZAVUDEV_API_KEY"]) response = client.senders.update_profile( "snd_abc123", about="Your trusted online store", description="We offer the best products at competitive prices with fast shipping and excellent customer service.", address="123 Main Street, San Francisco, CA 94102", email="support@example.com", websites=["https://example.com", "https://shop.example.com"], vertical="RETAIL" ) print("Profile updated successfully") ``` ```ruby Ruby theme={null} response = client.senders.update_profile( "snd_abc123", about: "Your trusted online store", description: "We offer the best products at competitive prices with fast shipping and excellent customer service.", address: "123 Main Street, San Francisco, CA 94102", email: "support@example.com", websites: ["https://example.com", "https://shop.example.com"], vertical: "RETAIL" ) puts "Profile updated successfully" ``` ```go Go theme={null} response, err := client.Senders.UpdateProfile(context.TODO(), "snd_abc123", zavudev.WhatsAppBusinessProfileUpdateParams{ About: zavudev.String("Your trusted online store"), Description: zavudev.String("We offer the best products at competitive prices with fast shipping and excellent customer service."), Address: zavudev.String("123 Main Street, San Francisco, CA 94102"), Email: zavudev.String("support@example.com"), Websites: []string{"https://example.com", "https://shop.example.com"}, Vertical: zavudev.String("RETAIL"), }) fmt.Println("Profile updated successfully") ``` ```php PHP theme={null} $response = $client->senders->updateProfile('snd_abc123', [ 'about' => 'Your trusted online store', 'description' => 'We offer the best products at competitive prices with fast shipping and excellent customer service.', 'address' => '123 Main Street, San Francisco, CA 94102', 'email' => 'support@example.com', 'websites' => ['https://example.com', 'https://shop.example.com'], 'vertical' => 'RETAIL', ]); echo "Profile updated successfully\n"; ``` ```bash cURL theme={null} curl -X PATCH "https://api.zavu.dev/v1/senders/snd_abc123/profile" \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "about": "Your trusted online store", "description": "We offer the best products at competitive prices with fast shipping and excellent customer service.", "address": "123 Main Street, San Francisco, CA 94102", "email": "support@example.com", "websites": ["https://example.com", "https://shop.example.com"], "vertical": "RETAIL" }' ``` > [!TIP] > > You only need to include the fields you want to update. Omitted fields will remain unchanged. ### Upload Profile Picture Upload a new profile picture for your WhatsApp Business account: ```typescript TypeScript theme={null} import Zavudev from '@zavudev/sdk'; const zavu = new Zavu({ apiKey: process.env.ZAVUDEV_API_KEY }); const response = await zavu.senders.uploadProfilePicture('snd_abc123', { imageUrl: 'https://example.com/logo.png', mimeType: 'image/png', }); console.log('Profile picture updated successfully'); ``` ```python Python theme={null} from zavudev import Zavu client = Zavu(api_key=os.environ["ZAVUDEV_API_KEY"]) response = client.senders.upload_profile_picture( "snd_abc123", image_url="https://example.com/logo.png", mime_type="image/png" ) print("Profile picture updated successfully") ``` ```ruby Ruby theme={null} response = client.senders.upload_profile_picture( "snd_abc123", image_url: "https://example.com/logo.png", mime_type: "image/png" ) puts "Profile picture updated successfully" ``` ```go Go theme={null} response, err := client.Senders.UploadProfilePicture(context.TODO(), "snd_abc123", zavudev.WhatsAppBusinessProfilePictureParams{ ImageURL: zavudev.String("https://example.com/logo.png"), MimeType: zavudev.String("image/png"), }) fmt.Println("Profile picture updated successfully") ``` ```php PHP theme={null} $response = $client->senders->uploadProfilePicture('snd_abc123', [ 'imageUrl' => 'https://example.com/logo.png', 'mimeType' => 'image/png', ]); echo "Profile picture updated successfully\n"; ``` ```bash cURL theme={null} curl -X POST "https://api.zavu.dev/v1/senders/snd_abc123/profile/picture" \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "imageUrl": "https://example.com/logo.png", "mimeType": "image/png" }' ``` ### Image Requirements * **Formats:** JPEG (`image/jpeg`) or PNG (`image/png`) * **Max size:** 5MB * **Recommended:** Square images work best ## Best Practices > [card] Keep it professional > > Use a clear logo or professional image that represents your brand. > [card] Be concise > > Write clear, concise descriptions that quickly communicate your value. > [card] Include contact info > > Add your email and website so customers can easily reach you. > [card] Choose the right category > > Select the business category that best matches your industry. ## API Reference > [card] [Get Profile](/api-reference/senders/get-profile) > > GET /v1/senders//profile > [card] [Update Profile](/api-reference/senders/update-profile) > > PATCH /v1/senders//profile > [card] [Upload Picture](/api-reference/senders/upload-profile-picture) > > POST /v1/senders//profile/picture # Template Approval Process Source: https://docs.zavu.dev/guides/whatsapp/templates/approval Understand how WhatsApp templates are reviewed and approved by Meta All WhatsApp templates must be approved by Meta before they can be used to send messages. This guide explains the approval process, common rejection reasons, and how to track template status. ## Approval Workflow ```mermaid theme={null} stateDiagram-v2 [*] --> draft: Create template draft --> pending: Submit for approval pending --> approved: Meta approves pending --> rejected: Meta rejects rejected --> draft: Edit and fix approved --> [*]: Ready to use ``` ## Submitting for Approval ### Via Dashboard 1. Go to **Senders** > Select sender > **Templates** tab 2. Find the template with "Draft" status 3. Click the **...** menu and select **Submit for Approval** 4. The status will change to "Pending" ### Via API ```typescript TypeScript theme={null} // Submit a draft template for approval await zavu.templates.submit({ templateId: 'tmpl_abc123', }); ``` ```python Python theme={null} # Submit a draft template for approval client.templates.submit(template_id="tmpl_abc123") ``` ```ruby Ruby theme={null} # Submit a draft template for approval client.templates.submit(template_id: "tmpl_abc123") ``` ```go Go theme={null} // Submit a draft template for approval _, err := client.Templates.Submit(context.TODO(), "tmpl_abc123", zavudev.TemplateSubmitParams{}) ``` ```php PHP theme={null} // Submit a draft template for approval $client->templates->submit('tmpl_abc123'); ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/templates/tmpl_abc123/submit \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` ## Sample Values for Variables Meta reviews a template against a sample value for each body variable. Send them as `variableExamples` when you submit: key a named variable (`{{customer_name}}`) by its name, and a positional one (`{{1}}`, `{{2}}`) as `var1`, `var2`. ```bash theme={null} curl -X POST https://api.zavu.dev/v1/templates/tmpl_abc123/submit \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "senderId": "sender_abc123", "category": "UTILITY", "variableExamples": { "var1": "Maria", "var2": "ORD-12345" } }' ``` Any variable you leave out gets a generic placeholder such as `Sample var1`, which Meta reviews less favourably. Send a realistic value for every variable. The SDKs do not include this field yet, so use the REST endpoint for it. ## Template Status | Status | Description | Can Send? | | - | - | - | | `draft` | Not yet submitted to Meta | No | | `pending` | Submitted, awaiting Meta review | No | | `approved` | Approved by Meta | Yes | | `rejected` | Rejected by Meta | No | ## Approval Timeline * **Typical review time:** 24-48 hours * **Complex templates:** Up to 72 hours * **Peak periods:** May take longer during high volume > [!NOTE] > > Meta reviews templates for compliance with their [Business Messaging Policy](https://www.whatsapp.com/legal/business-policy/). Ensure your templates follow these guidelines before submitting. ## Tracking Status Changes ### Webhooks Configure webhooks to receive real-time notifications when template status changes: ```json theme={null} { "id": "evt_1702000000000_abc123", "type": "template.status_changed", "timestamp": 1702000000000, "senderId": "sender_xyz", "projectId": "proj_abc", "data": { "templateId": "tmpl_123", "name": "order_confirmation", "previousStatus": "pending", "currentStatus": "approved", "rejectionReason": null } } ``` **Enable the webhook event:** 1. Go to **Senders** > Select sender > **Webhooks** tab 2. Add `template.status_changed` to your webhook events A template belongs to the WhatsApp Business Account, not to one sender, so when several senders share that account the event is delivered **once**, to a single sender: the first one, by creation date, that has an active webhook configured. Configure the webhook on the sender you use and it will arrive there. If two senders on the same account point at different endpoints, only the older of the two receives it — split them across accounts if both need to know. ### Polling Check template status via API: ```typescript theme={null} const template = await zavu.templates.retrieve({ templateId: 'tmpl_abc123' }); console.log('Status:', template.whatsappStatus); if (template.rejectionReason) { console.log('Rejection reason:', template.rejectionReason); } ``` ### Syncing from WhatsApp Two cases the webhook does not cover: a template created outside Zavu (in Meta Business Manager, or by another tool) never appears in Zavu, and a missed `template.status_changed` webhook leaves a template stuck in `pending`. `POST /v1/templates/sync` reconciles both. It imports Meta templates Zavu does not have — or links them to an existing template with the same name — and refreshes the status of the ones it already knows about. ```bash theme={null} curl -X POST https://api.zavu.dev/v1/templates/sync \ -H "Authorization: Bearer $ZAVU_API_KEY" \ -H "Content-Type: application/json" \ -d '{}' ``` ```json theme={null} { "accountsSynced": 1, "imported": 2, "linked": 1, "updated": 3, "skipped": 12, "errors": [] } ``` Every WhatsApp sender in the project is synced by default; pass `{"senderId": "sender_xyz"}` to sync just one. The call waits for Meta, so it takes a few seconds per account — list your templates right after it returns. Templates that Meta reports as rejected or disabled are not imported and are counted in `skipped`. ## Common Rejection Reasons ### Content Policy Violations > [!WARNING] > > Templates that violate Meta's policies will be rejected. Common violations include: > > * Misleading content > * Prohibited products/services > * Spammy language > * Missing opt-out information for marketing ### Formatting Issues | Issue | Solution | | - | - | | Invalid variable format | Use `{{1}}`, `{{2}}` format | | Too many variables | Limit to 10 variables per component | | Variable in wrong position | Variables can't be at start of template | | Button URL without variable | Dynamic URLs must include variables | ### Category Mismatch Templates may be rejected if the category doesn't match the content: * **UTILITY** templates shouldn't contain promotional content * **MARKETING** templates need clear opt-out language * **AUTHENTICATION** templates should only contain OTP/verification content Rejection is not the only outcome. More often Meta **recategorizes** the template instead — typically `UTILITY` to `MARKETING` — and approves it under the new category. It can also recategorize a template months after approval. Either way the template keeps working and every send on it is billed at the new category's rate. `template.category` reports the category Meta currently assigns, not the one you submitted. A recategorization is delivered as a `template.status_changed` webhook carrying `data.category`, and it fires even when the approval status did not move — so `previousStatus` and `currentStatus` can be identical on an event that changes what you pay. Branch on `category`, not only on `currentStatus`. To reconcile templates that were recategorized before you were listening, run `POST /v1/templates/sync`; it refreshes the category alongside the status. ### Language Issues * Template body must match the declared language * Special characters may cause issues in some languages * Emojis should be used sparingly ## Fixing Rejected Templates When a template is rejected: 1. **Check the rejection reason** in the dashboard or API response 2. **Edit the template** to fix the issues 3. **Resubmit** for approval > [!NOTE] > > A template's content cannot be edited in place. Meta reviews a name plus a > body, so fixing a rejection means creating a new template and submitting that. > Delete the rejected one once the replacement is approved. ```typescript theme={null} // Create the corrected template const fixed = await zavu.templates.create({ name: 'order_confirmation_v2', language: 'en', body: 'Updated message content that addresses the rejection reason', whatsappCategory: 'UTILITY', }); // Submit it to the WhatsApp Business Account on this sender await zavu.templates.submit(fixed.id, { senderId: 'sender_abc123' }); // Once the replacement is approved, drop the rejected one await zavu.templates.delete('tmpl_abc123'); ``` > [!NOTE] > > Editing an approved template will reset its status to "draft" and require re-approval. ## Best Practices for Approval > [card] Be Clear and Specific > > Vague templates are more likely to be rejected. Be specific about the purpose. > [card] Match Category to Content > > Ensure your template category accurately reflects the message type. > [card] Avoid Promotional Language in UTILITY > > UTILITY templates should be purely transactional without marketing content. > [card] Include Opt-Out for MARKETING > > Marketing templates should include clear unsubscribe instructions. ## Template Examples ### Good UTILITY Template ``` Hi {{1}}, your order #{{2}} has shipped! Tracking number: {{3}} Estimated delivery: {{4}} Track your package: https://example.com/track/{{2}} ``` ### Good MARKETING Template ``` Hi {{1}}! We have a special offer just for you: {{2}}% off your next purchase! Use code: {{3}} Valid until: {{4}} Reply STOP to unsubscribe. ``` ### Good AUTHENTICATION Template ``` Your verification code is {{1}}. This code expires in 10 minutes. Do not share this code with anyone. ``` ## Rate Limits | Action | Limit | | - | - | | Submit templates | 100/minute | | Templates per WABA | 250 total | ## Next Steps > [card] [Send Template Messages](/guides/whatsapp/templates/sending) > > Learn how to send messages using approved templates > [card] [Configure Webhooks](/guides/receiving-messages/webhooks) > > Set up webhooks to receive template status updates # OTP Authentication Source: https://docs.zavu.dev/guides/whatsapp/templates/otp Send verification codes via WhatsApp Authentication templates WhatsApp Authentication templates provide a secure, high-delivery-rate channel for sending one-time passwords (OTP) and verification codes. This guide covers creating, submitting, and sending OTP messages. ## Overview Authentication templates are purpose-built for verification codes: * **High delivery rates** - WhatsApp messages have 98%+ open rates * **Instant delivery** - Codes arrive in seconds * **User-friendly** - Copy button or auto-fill on Android * **Cost-effective** - Lowest messaging rates of all template categories * **Secure** - End-to-end encrypted delivery ## Prerequisites Before creating OTP templates, ensure you have: > [card] [Meta Business Verification](/guides/whatsapp/connect-whatsapp) > > Your Meta Business account must be verified > [card] [WhatsApp Connected](/guides/whatsapp/connect-whatsapp) > > A sender with WhatsApp Business Account configured > [card] Payment Method > > Valid payment method configured in Meta Business Manager > [card] [API Key](/authentication) > > A Zavu API key for your project ## Step 1: Create OTP Template ### Via Dashboard 1. Navigate to **Senders** in your dashboard 2. Select the sender with WhatsApp connected 3. Go to the **Templates** tab 4. Click **Create Template** 5. Configure the template: * **Name**: Use a descriptive name like `otp_verification` * **Channel**: Select WhatsApp only * **Category**: Select **AUTHENTICATION** * **Language**: Choose your language (e.g., `en`) 6. Configure the OTP button: * **Button Type**: Choose `Copy Code` or `One-Tap` (Android only) 7. Optional settings: * Enable **Security Recommendation** to add "Do not share this code" * Set **Code Expiration** (1-90 minutes) 8. Click **Create** ### Via API ```typescript TypeScript theme={null} import Zavudev from '@zavudev/sdk'; const zavu = new Zavudev({ apiKey: process.env["ZAVUDEV_API_KEY"], }); const template = await zavu.templates.create({ name: 'otp_verification', language: 'en', body: '{{1}} is your verification code.', category: 'AUTHENTICATION', buttons: [{ type: 'otp', text: 'Copy code', otpType: 'COPY_CODE' }], addSecurityRecommendation: true, codeExpirationMinutes: 10 }); console.log('Template ID:', template.id); console.log('Status:', template.status); // "draft" ``` ```python Python theme={null} import os from zavudev import Zavudev zavu = Zavudev( api_key=os.environ.get("ZAVUDEV_API_KEY"), ) template = zavu.templates.create( name="otp_verification", language="en", body="{{1}} is your verification code.", category="AUTHENTICATION", buttons=[{ "type": "otp", "text": "Copy code", "otp_type": "COPY_CODE" }], add_security_recommendation=True, code_expiration_minutes=10 ) print("Template ID:", template.id) print("Status:", template.status) # "draft" ``` ```ruby Ruby theme={null} template = client.templates.create( name: "otp_verification", language: "en", body: "{{1}} is your verification code.", category: "AUTHENTICATION", buttons: [{ type: "otp", text: "Copy code", otp_type: "COPY_CODE" }], add_security_recommendation: true, code_expiration_minutes: 10 ) puts "Template ID: #{template.id}" puts "Status: #{template.status}" # "draft" ``` ```go Go theme={null} template, err := client.Templates.New(context.TODO(), zavudev.TemplateCreateParams{ Name: zavudev.String("otp_verification"), Language: zavudev.String("en"), Body: zavudev.String("{{1}} is your verification code."), WhatsappCategory: zavudev.String("AUTHENTICATION"), Buttons: []zavudev.TemplateButtonParams{ { Type: zavudev.String("otp"), Text: zavudev.String("Copy code"), OTPType: zavudev.String("COPY_CODE"), }, }, AddSecurityRecommendation: zavudev.Bool(true), CodeExpirationMinutes: zavudev.Int(10), }) fmt.Println("Template ID:", template.ID) fmt.Println("Status:", template.Status) // "draft" ``` ```php PHP theme={null} $template = $client->templates->create([ 'name' => 'otp_verification', 'language' => 'en', 'body' => '{{1}} is your verification code.', 'category' => 'AUTHENTICATION', 'buttons' => [[ 'type' => 'otp', 'text' => 'Copy code', 'otpType' => 'COPY_CODE', ]], 'addSecurityRecommendation' => true, 'codeExpirationMinutes' => 10, ]); echo 'Template ID: ' . $template->id . "\n"; echo 'Status: ' . $template->status . "\n"; // "draft" ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/templates \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "otp_verification", "language": "en", "body": "{{1}} is your verification code.", "category": "AUTHENTICATION", "buttons": [{ "type": "otp", "text": "Copy code", "otpType": "COPY_CODE" }], "addSecurityRecommendation": true, "codeExpirationMinutes": 10 }' ``` > [!NOTE] > > Meta auto-generates the message body for authentication templates. The format is always: `{{1}} is your verification code.` followed by optional security text and expiration. ## Step 2: Submit for Meta Approval Authentication templates typically receive fast approval (within hours). ### Via Dashboard 1. Go to **Senders** > Select sender > **Templates** tab 2. Find your template with "Draft" status 3. Click the **...** menu and select **Submit for Approval** ### Via API ```typescript TypeScript theme={null} await zavu.templates.submit({ templateId: template.id, senderId: 'sender_abc123' }); // Check status const updated = await zavu.templates.retrieve({ templateId: template.id }); console.log('Status:', updated.status); // "pending" -> "approved" ``` ```python Python theme={null} zavu.templates.submit( template_id=template.id, sender_id="sender_abc123" ) # Check status updated = zavu.templates.retrieve(template_id=template.id) print("Status:", updated.status) # "pending" -> "approved" ``` ```ruby Ruby theme={null} client.templates.submit( template_id: template.id, sender_id: "sender_abc123" ) # Check status updated = client.templates.retrieve(template_id: template.id) puts "Status: #{updated.status}" # "pending" -> "approved" ``` ```go Go theme={null} _, err = client.Templates.Submit(context.TODO(), template.ID, zavudev.TemplateSubmitParams{ SenderID: zavudev.String("sender_abc123"), }) // Check status updated, err := client.Templates.Get(context.TODO(), template.ID) fmt.Println("Status:", updated.Status) // "pending" -> "approved" ``` ```php PHP theme={null} $client->templates->submit($template->id, [ 'senderId' => 'sender_abc123', ]); // Check status $updated = $client->templates->retrieve($template->id); echo 'Status: ' . $updated->status . "\n"; // "pending" -> "approved" ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/templates/tmpl_abc123/submit \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "senderId": "sender_abc123" }' ``` ### Expected Approval Time | Template Type | Typical Approval Time | | - | - | | AUTHENTICATION | 1-4 hours | | UTILITY | 24-48 hours | | MARKETING | 24-72 hours | > [!TIP] > > Authentication templates are prioritized by Meta and usually approved within hours. If approval takes longer, ensure your Meta Business is fully verified. ## Step 3: Send OTP Messages Once approved, send verification codes to users: ```typescript TypeScript theme={null} import Zavudev from '@zavudev/sdk'; const zavu = new Zavudev({ apiKey: process.env["ZAVUDEV_API_KEY"], }); // Generate your OTP code const otpCode = '123456'; const message = await zavu.messages.send({ to: '+14155551234', messageType: 'template', content: { templateId: 'tmpl_abc123', templateVariables: { '1': otpCode // The OTP code } } }); console.log('Message ID:', message.id); console.log('Status:', message.status); ``` ```python Python theme={null} import os from zavudev import Zavudev zavu = Zavudev( api_key=os.environ.get("ZAVUDEV_API_KEY"), ) # Generate your OTP code otp_code = "123456" message = zavu.messages.send( to="+14155551234", message_type="template", content={ "template_id": "tmpl_abc123", "template_variables": { "1": otp_code # The OTP code } } ) print("Message ID:", message.id) print("Status:", message.status) ``` ```ruby Ruby theme={null} # Generate your OTP code otp_code = "123456" message = client.messages.send_( to: "+14155551234", message_type: "template", content: { template_id: "tmpl_abc123", template_variables: { "1" => otp_code # The OTP code } } ) puts "Message ID: #{message.id}" puts "Status: #{message.status}" ``` ```go Go theme={null} // Generate your OTP code otpCode := "123456" message, err := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("+14155551234"), MessageType: zavudev.String("template"), Content: &zavudev.MessageContentParams{ TemplateID: zavudev.String("tmpl_abc123"), TemplateVariables: map[string]string{ "1": otpCode, // The OTP code }, }, }) fmt.Println("Message ID:", message.ID) fmt.Println("Status:", message.Status) ``` ```php PHP theme={null} // Generate your OTP code $otpCode = '123456'; $message = $client->messages->send([ 'to' => '+14155551234', 'messageType' => 'template', 'content' => [ 'templateId' => 'tmpl_abc123', 'templateVariables' => [ '1' => $otpCode, // The OTP code ], ], ]); echo 'Message ID: ' . $message->id . "\n"; echo 'Status: ' . $message->status . "\n"; ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "+14155551234", "messageType": "template", "content": { "templateId": "tmpl_abc123", "templateVariables": { "1": "123456" } } }' ``` ### What the User Sees The user receives a message like: ``` 123456 is your verification code. For your security, do not share this code. This code expires in 10 minutes. [Copy code] ``` ## OTP Button Types ### COPY\_CODE (Recommended) The **Copy Code** button allows users to tap and copy the code to their clipboard. ```typescript theme={null} buttons: [{ type: 'otp', text: 'Copy code', otpType: 'COPY_CODE' }] ``` **Benefits:** * Works on all platforms (iOS, Android, Web) * Simple user experience * No additional configuration required ### ONE\_TAP (Android Only) The **One-Tap** button enables automatic code filling in your Android app. ```typescript theme={null} buttons: [{ type: 'otp', text: 'Copy code', otpType: 'ONE_TAP', packageName: 'com.yourcompany.app', signatureHash: 'K8a+W1234...' }] ``` **Requirements:** * Android app only * Must provide `packageName` (Android package name) * Must provide `signatureHash` (Android app signature hash) **How to get your signature hash:** 1. In your Android app, use the SMS Retriever API helper: ```java theme={null} AppSignatureHelper helper = new AppSignatureHelper(context); ArrayList signatures = helper.getAppSignatures(); // Use the first signature String signatureHash = signatures.get(0); ``` 2. The hash is an 11-character string like `K8a+W1234ab` > [!WARNING] > > One-Tap auto-fill only works on Android. iOS users will see a standard copy button. Always ensure your flow works without auto-fill as a fallback. ## Template Options ### Security Recommendation Add a security disclaimer to your OTP message: ```typescript theme={null} addSecurityRecommendation: true ``` This adds: *"For your security, do not share this code."* ### Code Expiration Display when the code expires: ```typescript theme={null} codeExpirationMinutes: 10 // 1-90 minutes ``` This adds: *"This code expires in 10 minutes."* ## Best Practices > [card] Use Short Expiration > > Set expiration to 5-10 minutes for security. Shorter is better. > [card] Enable Security Warning > > Always enable `addSecurityRecommendation` to protect users from phishing. > [card] Generate Secure Codes > > Use cryptographically secure random number generators for OTP codes. > [card] Rate Limit Requests > > Limit OTP requests per user to prevent abuse (e.g., 5 per hour). ### OTP Code Guidelines * Use 6-digit numeric codes for balance of security and usability * Generate codes using secure random functions * Store codes with expiration timestamps * Invalidate codes after successful verification * Limit verification attempts (e.g., 3 tries per code) ```typescript theme={null} // Example: Generate secure OTP import crypto from 'crypto'; function generateOTP(): string { return crypto.randomInt(100000, 999999).toString(); } ``` ## Common Issues | Issue | Solution | | - | - | | Template rejected | Ensure category is AUTHENTICATION and content is purely verification-related | | Code not displaying | Check that variable `{{1}}` is provided in `templateVariables` | | One-Tap not working | Verify `packageName` and `signatureHash` are correct for your Android app | | Slow delivery | Check recipient's WhatsApp status and your sender's quality rating | | Button not showing | Ensure the template has `buttons` configured with OTP type | ## Webhook Events Track OTP delivery status via webhooks: ```json theme={null} { "id": "evt_1702000000000_abc123", "type": "message.delivered", "timestamp": 1702000000000, "data": { "messageId": "msg_xyz789", "to": "+14155551234", "channel": "whatsapp", "status": "delivered" } } ``` Configure webhooks to receive: * `message.sent` - OTP accepted by WhatsApp * `message.delivered` - OTP delivered to device * `message.failed` - Delivery failed See [Webhooks Guide](/guides/receiving-messages/webhooks) for setup instructions. ## Next Steps > [card] [Template Approval](/guides/whatsapp/templates/approval) > > Learn more about the approval process and common rejection reasons > [card] [Configure Webhooks](/guides/receiving-messages/webhooks) > > Set up webhooks to track delivery status > [card] [Smart Routing](/guides/sending-messages/smart-routing) > > Use smart routing to automatically fall back to SMS if WhatsApp fails > [card] [Rate Limiting](/concepts/rate-limiting) > > Understand API rate limits for high-volume OTP sending # Creating WhatsApp Templates Source: https://docs.zavu.dev/guides/whatsapp/templates/overview Learn how to create and manage WhatsApp message templates for your business WhatsApp templates are pre-approved message formats that allow you to initiate conversations with users outside the 24-hour messaging window. This guide covers how to create, submit, and manage templates. ## Overview Templates are required for: * Starting new conversations with users * Re-engaging users after the 24-hour window expires * Sending transactional notifications (order updates, appointments, etc.) ## Template Structure A WhatsApp template consists of: | Component | Required | Description | | - | - | - | | **Name** | Yes | Unique identifier (lowercase, underscores only) | | **Language** | Yes | Language code (e.g., `en`, `es`, `pt`) | | **Category** | Yes | UTILITY, MARKETING, or AUTHENTICATION | | **Body** | Yes | Main message content with variables | | **Header** | No | Text, image, video, or document | | **Footer** | No | Short footer text | | **Buttons** | No | Quick reply or URL buttons (max 3) | ## Creating a Template ### Via Dashboard 1. Navigate to **Senders** in your dashboard 2. Select the sender you want to create templates for 3. Go to the **Templates** tab 4. Click **Create Template** 5. Fill in the template details and submit ### Via API ```typescript TypeScript theme={null} import Zavudev from '@zavudev/sdk'; const zavu = new Zavudev({ apiKey: process.env["ZAVUDEV_API_KEY"], }); const template = await zavu.templates.create({ name: 'order_confirmation', language: 'en', body: 'Hi {{1}}, your order {{2}} has been confirmed and will ship within 24 hours.', category: 'UTILITY', variables: ['customer_name', 'order_id'], }); console.log('Template ID:', template.id); console.log('Status:', template.whatsappStatus); // "draft" ``` ```python Python theme={null} import os from zavudev import Zavudev zavu = Zavudev( api_key=os.environ.get("ZAVUDEV_API_KEY"), ) template = zavu.templates.create( name="order_confirmation", language="en", body="Hi {{1}}, your order {{2}} has been confirmed and will ship within 24 hours.", category="UTILITY", variables=["customer_name", "order_id"] ) print("Template ID:", template.id) print("Status:", template.whatsapp_status) # "draft" ``` ```ruby Ruby theme={null} template = client.templates.create( name: "order_confirmation", language: "en", body: "Hi {{1}}, your order {{2}} has been confirmed and will ship within 24 hours.", category: "UTILITY", variables: ["customer_name", "order_id"] ) puts "Template ID: #{template.id}" puts "Status: #{template.whatsapp_status}" # "draft" ``` ```go Go theme={null} template, err := client.Templates.New(context.TODO(), zavudev.TemplateCreateParams{ Name: zavudev.String("order_confirmation"), Language: zavudev.String("en"), Body: zavudev.String("Hi {{1}}, your order {{2}} has been confirmed and will ship within 24 hours."), WhatsappCategory: zavudev.String("UTILITY"), Variables: []string{"customer_name", "order_id"}, }) fmt.Println("Template ID:", template.ID) fmt.Println("Status:", template.WhatsappStatus) // "draft" ``` ```php PHP theme={null} $template = $client->templates->create([ 'name' => 'order_confirmation', 'language' => 'en', 'body' => 'Hi {{1}}, your order {{2}} has been confirmed and will ship within 24 hours.', 'whatsappCategory' => 'UTILITY', 'variables' => ['customer_name', 'order_id'], ]); echo 'Template ID: ' . $template->id . "\n"; echo 'Status: ' . $template->whatsappStatus . "\n"; // "draft" ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/templates \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "order_confirmation", "language": "en", "body": "Hi {{1}}, your order {{2}} has been confirmed and will ship within 24 hours.", "category": "UTILITY", "variables": ["customer_name", "order_id"] }' ``` ## Template Variables Use numbered placeholders for dynamic content: ``` Hi {{1}}, your order {{2}} is ready for pickup at {{3}}. ``` When sending a message, provide the variable values: ```typescript theme={null} await zavu.messages.send({ to: '+14155551234', messageType: 'template', content: { templateId: 'tmpl_abc123', templateVariables: { '1': 'John', '2': 'ORD-12345', '3': '123 Main St' } } }); ``` Templates can also use **named** placeholders (`{{customer_name}}`) and **text-header** variables. For how named vs positional formats work and how to send them, see [How Variables Work](/guides/whatsapp/templates/sending#how-variables-work). ## Template Categories ### UTILITY For transactional messages with specific, agreed-upon information. **Examples:** * Order confirmations * Shipping updates * Appointment reminders * Account notifications **Cost:** Lower messaging rates ### MARKETING For promotional content and offers. **Examples:** * Sales announcements * Product recommendations * Newsletter content * Promotional offers **Cost:** Higher messaging rates ### AUTHENTICATION For one-time passwords and verification codes. **Examples:** * Login verification * Two-factor authentication * Account verification **Cost:** Lowest messaging rates ## Adding Headers Add a header to make your template more engaging: ```typescript theme={null} const template = await zavu.templates.create({ name: 'shipping_update', language: 'en', body: 'Your package {{1}} is on its way!', category: 'UTILITY', headerType: 'text', headerContent: 'Shipping Update', }); ``` ### Header Types | Type | Description | | - | - | | `text` | Plain text header | | `image` | Image header (provide URL) | | `video` | Video header (provide URL) | | `document` | PDF or document header | ## Adding Buttons Templates can include up to 3 buttons: ```typescript theme={null} const template = await zavu.templates.create({ name: 'order_tracking', language: 'en', body: 'Your order {{1}} has shipped!', category: 'UTILITY', buttons: [ { type: 'url', text: 'Track Order', url: 'https://example.com/track/{{1}}' }, { type: 'quick_reply', text: 'Contact Support' } ] }); ``` ### Button Types | Type | Description | | - | - | | `quick_reply` | Sends predefined response | | `url` | Opens a URL (can include variables) | | `phone` | Initiates a phone call | | `request_contact_info` | Asks the recipient to share their phone number. The label is fixed by WhatsApp (**Share Contact Info**), so `text` is not required. Useful for [username contacts](/guides/whatsapp/usernames) known only by BSUID. | ```typescript theme={null} const template = await zavu.templates.create({ name: 'callback_request', language: 'en', body: 'Hi {{1}}, we need a phone number to schedule your callback.', category: 'UTILITY', buttons: [ { type: 'request_contact_info' } ] }); ``` The recipient's answer arrives as an inbound `contact` message, and Zavu links the shared number to the contact automatically. See [Contact Info Request Messages](/guides/whatsapp/messages/request-contact-info). ## Best Practices > [card] Keep it concise > > Templates have character limits. Body text should be under 1024 characters. > [card] Use clear variables > > Name your variables descriptively in the documentation for easier maintenance. > [card] Choose the right category > > Incorrect categorization may lead to rejection or higher costs. > [card] Test before production > > Always test templates with your own number first. ## Naming Conventions Template names must: * Be unique within your account * Use only lowercase letters, numbers, and underscores * Not exceed 512 characters * Not start with a number **Good examples:** * `order_confirmation_v2` * `appointment_reminder` * `shipping_update_en` **Bad examples:** * `Order Confirmation` (spaces not allowed) * `2fa_code` (starts with number) * `ORDER_CONFIRMATION` (uppercase not allowed) ## Next Steps > [card] [Submit for Approval](/guides/whatsapp/templates/approval) > > Learn how to submit templates and track approval status > [card] [Send Template Messages](/guides/whatsapp/templates/sending) > > Start sending messages with your approved templates # Templates Source: https://docs.zavu.dev/guides/whatsapp/templates/sending Pre-approved messages for WhatsApp Business outside the 24-hour window Templates are pre-approved message formats required for contacting users outside the 24-hour conversation window. They must be submitted to Meta for approval before use. ## Why Templates? * **Required by WhatsApp** - Only way to message users outside 24-hour window * **Consistency** - Ensure uniform messaging across your team * **Compliance** - Meta reviews content for quality and policy compliance * **Analytics** - Track performance by template ## Quick Start: Send a Template Message If you already have an approved template, here's how to send it with variables: ```typescript TypeScript theme={null} const message = await zavu.messages.send({ to: "+56912345678", messageType: "template", content: { templateId: "tpl_abc123", templateVariables: { "1": "John", "2": "ORD-12345", "3": "January 20, 2025" } } }); ``` ```python Python theme={null} message = zavu.messages.send( to="+56912345678", message_type="template", content={ "template_id": "tpl_abc123", "template_variables": { "1": "John", "2": "ORD-12345", "3": "January 20, 2025" } } ) ``` ```ruby Ruby theme={null} message = client.messages.send_( to: "+56912345678", message_type: "template", content: { template_id: "tpl_abc123", template_variables: { "1" => "John", "2" => "ORD-12345", "3" => "January 20, 2025" } } ) ``` ```go Go theme={null} message, err := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("+56912345678"), MessageType: zavudev.String("template"), Content: &zavudev.MessageContentParams{ TemplateID: zavudev.String("tpl_abc123"), TemplateVariables: map[string]string{ "1": "John", "2": "ORD-12345", "3": "January 20, 2025", }, }, }) ``` ```php PHP theme={null} $message = $client->messages->send([ 'to' => '+56912345678', 'messageType' => 'template', 'content' => [ 'templateId' => 'tpl_abc123', 'templateVariables' => [ '1' => 'John', '2' => 'ORD-12345', '3' => 'January 20, 2025', ], ], ]); ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer zv_live_xxx" \ -H "Content-Type: application/json" \ -d '{ "to": "+56912345678", "messageType": "template", "content": { "templateId": "tpl_abc123", "templateVariables": { "1": "John", "2": "ORD-12345", "3": "January 20, 2025" } } }' ``` ### How Variables Work A template can use one of two placeholder formats, and you pass values the same way for both: * **Positional**: `{{1}}`, `{{2}}`, `{{3}}` — keyed by position. * **Named**: `{{customer_name}}`, `{{order_id}}` — keyed by name. ``` Template body: "Hi {{1}}, your order #{{2}} is confirmed. Delivery: {{3}}." ^ ^ ^ | | | Variables: "1": "John" "2": "ORD-12345" "3": "January 20, 2025" ``` In both cases you provide the values through `templateVariables`. The keys match the placeholders in the template body — numbers for positional templates, names for named templates: ```json theme={null} // Positional template "templateVariables": { "1": "John", "2": "ORD-12345" } // Named template "templateVariables": { "customer_name": "John", "order_id": "ORD-12345" } ``` > [!WARNING] > > You must provide values for **all** variables defined in the template. Missing variables will cause the message to fail (`#100 INVALID_PARAMETER` from Meta). > [!WARNING] > > A variable value may not contain **line breaks, tabs or more than 4 consecutive spaces**. WhatsApp refuses those parameters, so Zavu rejects the request with `400 invalid_request` before sending, and `details.field` names the value (for example `content.templateVariables.2`). Flatten multi-line text first, for example by replacing line breaks with `·`. Templates sent over SMS or Telegram accept line breaks. #### Named vs Positional — which does my template use? You normally don't need to care: Zavu detects the format per template and builds the correct Meta payload. The distinction matters only when you decide what keys to use in `templateVariables`: | How the template was created | Format at Meta | Keys to use | | - | - | - | | Created in Zavu (`POST /v1/templates`) | Positional — Zavu converts `{{name}}` → `{{1}}` at submit time | Position numbers (`"1"`, `"2"`) **or** the original names | | Imported from an existing WhatsApp Business Account | Whatever Meta has on file — **named or positional** | Match the placeholders in the template body | > [!TIP] > > Fetch the template (`GET /v1/templates/{id}`) and read its `body` to see which placeholders it expects. If the body shows `{{customer_name}}`, use `{ "customer_name": "..." }`; if it shows `{{1}}`, use `{ "1": "..." }`. ### Text Header Variables A template can have a **text header** with its own variable, e.g. `Invitación Boda {{novios}}`. The header variable is independent from the body variables, but you pass its value through the **same** `templateVariables` object, keyed by the header placeholder's name (or `{{1}}` for a positional header): ```typescript TypeScript theme={null} // Template header: "Invitación Boda {{novios}}" // Template body: "Estimado/a {{nombre}}, te corresponden {{pases}} boletos." await zavu.messages.send({ to: "+5212345678901", messageType: "template", content: { templateId: "tpl_wedding", templateVariables: { novios: "Jorge y Laura", // header variable nombre: "Nestor Alvares", // body variables pases: "4" } } }); ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer zv_live_xxx" \ -H "Content-Type: application/json" \ -d '{ "to": "+5212345678901", "messageType": "template", "content": { "templateId": "tpl_wedding", "templateVariables": { "novios": "Jorge y Laura", "nombre": "Nestor Alvares", "pases": "4" } } }' ``` > [!NOTE] > > WhatsApp text headers allow **at most one** variable. Static text headers (no placeholder) need no value — Meta has the text baked into the approved template. ## Media headers When a template's header is an image, video or document, Zavu sends the file stored on the template. To send a different file in one message (for example a per-customer PDF), pass `templateHeaderMediaUrl`. For a document header you can also set the file name the recipient sees with `templateHeaderFilename`. ```typescript TypeScript theme={null} await zavu.messages.send({ to: "+56912345678", messageType: "template", content: { templateId: "tmpl_abc123", // header type: document templateVariables: { "1": "Maria", "2": "INV-1042" }, templateHeaderMediaUrl: "https://files.example.com/invoices/INV-1042.pdf?sig=abc123", templateHeaderFilename: "invoice-1042.pdf", }, }); ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer $ZAVU_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "+56912345678", "messageType": "template", "content": { "templateId": "tmpl_abc123", "templateVariables": { "1": "Maria", "2": "INV-1042" }, "templateHeaderMediaUrl": "https://files.example.com/invoices/INV-1042.pdf?sig=abc123", "templateHeaderFilename": "invoice-1042.pdf" } }' ``` * `templateHeaderMediaUrl` must be an HTTPS URL that WhatsApp can fetch when the message is sent. It replaces the template's stored file for this message only. * `templateHeaderFilename` is only valid when the template's header is `document`. On its own it names the template's stored file. * Both fields are refused with `400 invalid_request` (`details.field` names the field) when the template's header does not match, before anything is sent or charged. * Without these fields, the file stored on the template is sent, as before. ## Full Workflow Templates follow a 3-step process: **Create** -> **Submit for Approval** -> **Send**. ### Step 1: Create a Template ```typescript TypeScript theme={null} const template = await zavu.templates.create({ name: "order_confirmation", language: "en", body: "Hi {{1}}, your order #{{2}} has been confirmed! Estimated delivery: {{3}}.", whatsappCategory: "UTILITY", variables: ["customer_name", "order_id", "delivery_date"] }); console.log(template.id); // "tpl_abc123" console.log(template.status); // "draft" ``` ```python Python theme={null} template = zavu.templates.create( name="order_confirmation", language="en", body="Hi {{1}}, your order #{{2}} has been confirmed! Estimated delivery: {{3}}.", whatsapp_category="UTILITY", variables=["customer_name", "order_id", "delivery_date"] ) print(template.id) # "tpl_abc123" print(template.status) # "draft" ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/templates \ -H "Authorization: Bearer zv_live_xxx" \ -H "Content-Type: application/json" \ -d '{ "name": "order_confirmation", "language": "en", "body": "Hi {{1}}, your order #{{2}} has been confirmed! Estimated delivery: {{3}}.", "whatsappCategory": "UTILITY", "variables": ["customer_name", "order_id", "delivery_date"] }' ``` The `variables` array is optional and purely for documentation - it helps you remember what each position represents. ### Step 2: Submit for Approval Templates start in `draft` status. You need to explicitly submit them to Meta for review: ```typescript TypeScript theme={null} const submitted = await zavu.templates.submit("tpl_abc123", { senderId: "sender_12345", category: "UTILITY" }); console.log(submitted.status); // "pending" ``` ```python Python theme={null} submitted = zavu.templates.submit( "tpl_abc123", sender_id="sender_12345", category="UTILITY" ) print(submitted.status) # "pending" ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/templates/tpl_abc123/submit \ -H "Authorization: Bearer zv_live_xxx" \ -H "Content-Type: application/json" \ -d '{ "senderId": "sender_12345", "category": "UTILITY" }' ``` > [!NOTE] > > The `senderId` must be a sender with a WhatsApp Business Account connected. The template is submitted to Meta through that WABA. ### Step 3: Check Approval Status ```bash theme={null} curl https://api.zavu.dev/v1/templates/tpl_abc123 \ -H "Authorization: Bearer zv_live_xxx" ``` ```json theme={null} { "id": "tpl_abc123", "name": "order_confirmation", "status": "approved", "category": "UTILITY", "body": "Hi {{1}}, your order #{{2}} has been confirmed! Estimated delivery: {{3}}.", "variables": ["customer_name", "order_id", "delivery_date"] } ``` | Status | Description | | - | - | | `draft` | Created but not yet submitted to Meta | | `pending` | Submitted, awaiting Meta review | | `approved` | Ready to use | | `rejected` | Not approved - check rejection reason | > [!WARNING] > > Only `approved` templates can be used to send messages. Attempting to use draft, pending, or rejected templates will fail. ### Step 4: Send the Template Once approved, send it using the positional variables (see [Quick Start](#quick-start-send-a-template-message) above). ## Template Categories | Category | Use Case | Approval Speed | | - | - | - | | `UTILITY` | Order updates, shipping, account alerts | Fast (hours) | | `MARKETING` | Promotions, offers, newsletters | Slower (days) | | `AUTHENTICATION` | OTPs, verification codes | Fast (hours) | > [!NOTE] > > Choose the category that best matches your use case. Miscategorization can lead to rejection. ## Templates with Buttons Add call-to-action or quick reply buttons: ```bash theme={null} curl -X POST https://api.zavu.dev/v1/templates \ -H "Authorization: Bearer zv_live_xxx" \ -H "Content-Type: application/json" \ -d '{ "name": "order_delivered", "language": "en", "body": "Hi {{1}}, your order has been delivered!", "whatsappCategory": "UTILITY", "variables": ["customer_name"], "buttons": [ { "type": "url", "text": "Track Order", "url": "https://example.com/track/{{1}}" }, { "type": "quick_reply", "text": "Rate Delivery" } ] }' ``` ### Button Types | Type | Description | Max | | - | - | - | | `quick_reply` | User response button | 3 buttons | | `url` | Opens a URL (can include variables) | - | | `phone` | Initiates a phone call | - | | `otp` | Copy code / one-tap autofill for authentication | - | ### Sending Templates with Dynamic URL Buttons When a template has a URL button with a `{{1}}` placeholder, pass the substitution under `templateButtonVariables` keyed by the **button's position** in the template's `buttons` array. `templateVariables` is for body placeholders. `templateButtonVariables` is for buttons. They use **different keys**: * `templateVariables` keys → position of the placeholder in the body text (`"1"`, `"2"`, ...). * `templateButtonVariables` keys → index of the button in the `buttons` array (`"0"`, `"1"`, `"2"`). > [!WARNING] > > **WhatsApp URL buttons only accept `{{1}}` (positional, numeric, no whitespace, no name).** Even though body placeholders may use named parameters like `{{name}}`, URL buttons do not. Anything else (`{{token}}`, `{{ 1 }}`, `{{user.id}}`, etc.) is approved by Meta as literal text in the URL — there is no way to substitute it later. > > If your template was approved with a non-`{{1}}` placeholder in a URL button, you must recreate the template with `{{1}}` and resubmit it for approval. Zavu now returns `400 invalid_request` instead of silently delivering broken URLs like `https://...%7B%7Bvariable%7D%7D`. Example. Suppose your approved template looks like this: ```json theme={null} { "id": "tpl_discipline_report", "body": "Hi {{1}}, your child received a {{2}}.", "buttons": [ { "type": "url", "text": "View report", "url": "https://reporte.link/{{1}}" } ] } ``` Send it like this: ```typescript TypeScript theme={null} await zavu.messages.send({ to: "+56912345678", messageType: "template", content: { templateId: "tpl_discipline_report", templateVariables: { "1": "Marco Ordaz", "2": "report for inappropriate behavior" }, templateButtonVariables: { "0": "abc-report-token" } } }); ``` ```python Python theme={null} zavu.messages.send( to="+56912345678", message_type="template", content={ "template_id": "tpl_discipline_report", "template_variables": { "1": "Marco Ordaz", "2": "report for inappropriate behavior", }, "template_button_variables": { "0": "abc-report-token", }, }, ) ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer zv_live_xxx" \ -H "Content-Type: application/json" \ -d '{ "to": "+56912345678", "messageType": "template", "content": { "templateId": "tpl_discipline_report", "templateVariables": { "1": "Marco Ordaz", "2": "report for inappropriate behavior" }, "templateButtonVariables": { "0": "abc-report-token" } } }' ``` The recipient receives a button that opens `https://reporte.link/abc-report-token`. #### Multiple buttons For templates with several buttons, supply one entry per dynamic button. Static URL buttons (no `{{1}}`) and `quick_reply` buttons are not included in `templateButtonVariables`. ```json theme={null} { "buttons": [ { "type": "url", "text": "View order", "url": "https://shop.example.com/orders/{{1}}" }, { "type": "quick_reply", "text": "Track" }, { "type": "url", "text": "Help", "url": "https://help.example.com/" } ] } ``` ```json theme={null} { "templateButtonVariables": { "0": "ORD-12345" } } ``` Index `1` (quick reply) and index `2` (static URL) are skipped. #### Validation rules | Rule | Behavior | | - | - | | URL has `{{1}}` and `templateButtonVariables[index]` provided | ✅ delivered with substitution | | URL has `{{1}}` and `templateButtonVariables[index]` missing | ❌ `400 invalid_request: Missing URL button parameter at index N` | | URL has `{{name}}`, `{{ 1 }}`, `{{token}}`, etc. | ❌ `400 invalid_request: ... WhatsApp treats it as literal text. Recreate the template with "{{1}}" and resubmit it for approval.` | | URL has more than one placeholder (e.g. `/{{1}}/{{2}}`) | ❌ `400 invalid_request: at most one positional variable` | | URL has no placeholder (static link) | ✅ ignored — do not include the index in `templateButtonVariables` | ## Authentication Templates (OTP) For verification codes, use `AUTHENTICATION` category with OTP buttons: ```bash theme={null} curl -X POST https://api.zavu.dev/v1/templates \ -H "Authorization: Bearer zv_live_xxx" \ -H "Content-Type: application/json" \ -d '{ "name": "login_otp", "language": "en", "body": "{{1}} is your verification code.", "whatsappCategory": "AUTHENTICATION", "variables": ["otp_code"], "buttons": [ { "type": "otp", "text": "Copy Code", "otpType": "COPY_CODE" } ], "addSecurityRecommendation": true, "codeExpirationMinutes": 5 }' ``` Send the OTP: ```bash theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer zv_live_xxx" \ -H "Content-Type: application/json" \ -d '{ "to": "+56912345678", "messageType": "template", "content": { "templateId": "tpl_otp123", "templateVariables": { "1": "482910" } } }' ``` See the [OTP Templates guide](/guides/whatsapp/templates/otp) for more details. ## Managing Templates ### List Templates ```bash theme={null} curl https://api.zavu.dev/v1/templates \ -H "Authorization: Bearer zv_live_xxx" ``` ### Delete Template ```bash theme={null} curl -X DELETE https://api.zavu.dev/v1/templates/tpl_abc123 \ -H "Authorization: Bearer zv_live_xxx" ``` ## Best Practices ### Naming Conventions Use descriptive, lowercase names with underscores: ``` order_confirmation shipping_update password_reset appointment_reminder promotional_offer ``` ### Content Guidelines **Do:** * Use clear, concise language * Include dynamic variables for personalization * Provide value to the recipient * Test with real data before production use **Don't:** * Use placeholder text like "\[insert name]" * Include excessive capitalization or punctuation * Send promotional content without consent * Use misleading or clickbait content ## Common Rejection Reasons | Reason | Solution | | - | - | | Variable syntax error | Use `{{1}}`, `{{2}}` positional format | | Category mismatch | Choose correct category for content | | Promotional in UTILITY | Use `MARKETING` category for promotions | | Missing opt-out | Include unsubscribe option for marketing | | Policy violation | Review Meta's commerce and messaging policies | ## Testing Templates Test with your own phone number before sending to customers: ```bash theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer zv_test_xxx" \ -H "Content-Type: application/json" \ -d '{ "to": "+your_phone_number", "messageType": "template", "content": { "templateId": "tpl_abc123", "templateVariables": { "1": "Test User", "2": "TEST-001", "3": "Tomorrow" } } }' ``` > [!TIP] > > Use test mode API keys (`zv_test_xxx`) during development to avoid charges. # Troubleshooting WhatsApp Signup Source: https://docs.zavu.dev/guides/whatsapp/troubleshooting-signup What each failure in the Meta signup dialog means, and what fixes it Connecting WhatsApp runs through Meta's embedded signup dialog. When it fails, the dashboard reports the failure to the Zavu assistant, which opens with a diagnosis. This page is the same knowledge, organised by where the signup gave up, so you can read it without the assistant. Four places can fail, in order. Find the one that matches what you saw. ## 1. Before Meta's window opened You never saw Meta at all. | What you saw | Cause | Fix | | - | - | - | | Nothing happened, then "your browser blocked the WhatsApp window" | The browser blocked the popup. In-app browsers and strict privacy settings do this. | Allow pop-ups for the dashboard, then **Retry connection**. | | "Facebook's script did not load" / "Facebook SDK not loaded" | An ad blocker, privacy extension or network filter stopped `facebook.com`. | Disable the blocker for the dashboard or use a plain Chrome profile, refresh, retry. | | "Meta sign-in only works on HTTPS" | The dashboard is open over plain `http` on a custom host. | Open the `https` address. | | "WhatsApp signup is not configured" | The deployment is missing its Meta configuration. This is on Zavu's side. | Contact support. Nothing on your side fixes it. | ## 2. Inside Meta's window The window closed or refused before the end. The message names the reason; Meta's own code is the strongest evidence. | Message | Cause | Fix | | - | - | - | | "The Meta window was closed before finishing. Nothing was connected." | You closed the window. | Nothing is left half-done. Retry when ready. | | "You need to authorize the app to continue." | A permission was declined or the consent screen was not finished. | Retry and accept every permission Meta lists. All of them are required. | | Meta reference **#1752206** — "Meta could not add your Facebook profile to a business portfolio" | You signed in as a Page or a professional account, or the Facebook account is brand new. | Sign in with your personal Facebook profile, confirm its email and phone, retry. Accounts created today are often blocked for a few hours. | | **#1752224** or **#1752089** — "Meta is still processing an earlier attempt" | An earlier attempt to create the business portfolio is still in progress. | Wait a few minutes, retry. | | **#1690114** — "reached Meta's limit of business portfolios" | Your Facebook account is at Meta's portfolio limit. | Pick a portfolio you already own, or remove one in Meta Business Suite. | | **#2388098** or **#2494067** — "maximum number of WhatsApp Business Accounts" | The portfolio is at its WhatsApp Business Account limit. | Complete Meta business verification, or delete an unused WhatsApp Business Account in Meta Business Suite. | | **#1357053** — "Meta has restricted this action" | Meta restricted the Facebook account or portfolio. | Try another portfolio, or contact Meta Business Support. Zavu cannot lift a Meta restriction. | | "Meta could not finish the connection: …" | Any other Meta error, quoted verbatim. | Keep the reference, trace and session ids the dashboard shows; Meta Business Support asks for them. | ## 3. Meta finished but handed Zavu nothing | Message | Cause | Fix | | - | - | - | | "Could not retrieve WhatsApp Business Account information" | Meta's window did not post an account id back. Usually a browser extension swallowed the message, or a screen in Meta's dialog was skipped. | Complete every screen in Meta's dialog, use Chrome with extensions off, retry. | | "Could not retrieve phone number information" | Same, for the phone number. The phone number screen is the one most often skipped. | Finish the phone number screen, retry. | | "No authorization code received from Meta" | Meta returned no authorization code. | Retry. | | "This connection attempt expired" | The same authorization was submitted twice. | Close the window and start the signup again from the button. | ## 4. Meta finished, Zavu refused | Message | Cause | Fix | | - | - | - | | "This phone number is already registered to another WhatsApp account" | The number is on another WhatsApp account: the WhatsApp Business App or personal WhatsApp on a phone, or another provider's WhatsApp Business Account. | If it is on another provider, remove it in [WhatsApp Manager](https://business.facebook.com/wa/manage/phone-numbers/), wait up to three minutes, retry. If it is on a phone, either keep the WhatsApp Business App and connect in coexistence mode, or delete the WhatsApp account from that phone first. Personal WhatsApp has no coexistence: delete it from the phone or use a different number. | | "Failed to register phone number: …" | The connection exists but the number is in `pending_registration`. It cannot send or receive until registration succeeds. Zavu retries every hour. | See the table below. | ### Registration failures The sender shows as connected but the number does not send or receive. The reason is in the error message. | Meta error | Cause | Fix | | - | - | - | | `133005` | A two-step verification PIN was set from another app. | Turn two-step verification off in WhatsApp Manager, or reconnect once you have the PIN. | | `2593005` | The number never completed its SMS or call verification. | Verify the number in WhatsApp Manager. | | "not verified" | The business is not verified with Meta. | Complete Meta business verification. The hourly retry clears it on its own. | | "display name" | Meta rejected the display name. | Change the display name in WhatsApp Manager, then reconnect. | | `133016` | Ten register attempts in 72 hours. | Wait out the window. Do not keep retrying. | | `190` | The access token no longer works. | Reconnect from the button. | ## Route-specific causes **A number bought in Zavu.** Meta verifies it by a phone **call**; choosing SMS never delivers a code. A number still under regulatory review cannot be connected until it is approved. A Brazilian number cannot complete Meta's registration; use coexistence with a WhatsApp Business App number instead. **Coexistence (keeping the WhatsApp Business App).** It needs the number registered on WhatsApp Business (not personal WhatsApp), admin access to the Facebook business that owns it, and a supported country: Nigeria and South Africa are not. If the "Connect WhatsApp Business App" option never appears in Meta's dialog, one of those is missing. If the number's credit line is still shared with another WhatsApp partner, Meta refuses the switch until you follow Meta's switch-partner guide. ## Next Steps * [Connect WhatsApp](/guides/whatsapp/connect-whatsapp) * [WhatsApp Business App Connection](/guides/whatsapp/business-app) * [WhatsApp Error Codes](/guides/whatsapp/error-codes) # Usernames & BSUIDs Source: https://docs.zavu.dev/guides/whatsapp/usernames Handle WhatsApp contacts who adopted a username and hid their phone number. Receive, reply and send using business-scoped user IDs (BSUIDs). WhatsApp users can adopt a **username** (like `@sheena`) and hide their phone number from businesses. When that happens, you no longer see their phone number anywhere: instead, every webhook and API response identifies them by a **business-scoped user ID (BSUID)**. ``` US.13491208655302741918 ``` A BSUID is stable per business: the same user always has the same BSUID with you, but a *different* BSUID with every other business. You cannot derive a phone number from it. Zavu handles the identity plumbing automatically. Contacts are matched and de-duplicated by BSUID, conversations stay threaded, and once Zavu learns the phone number, both identifiers point to the same contact. What you need to adapt in your integration is small, and it is all on this page. ## Receiving messages from a username contact The `message.inbound` webhook fires as usual, but `data.from` contains the BSUID instead of an E.164 phone number. `data.contactId` and `data.whatsapp.bsuid` identify the person either way: ```json theme={null} { "id": "evt_1705312200000_abc123", "type": "message.inbound", "timestamp": 1705312200000, "senderId": "snd_abc123", "projectId": "prj_xyz789", "data": { "messageId": "msg_xyz789", "conversationId": "js723987cyghwqxxaxcf590qd18axd95", "contactId": "jd7x2k3m4n5p6q7r8s9t0abc", "whatsapp": { "bsuid": "US.13491208655302741918", "username": "sheena" }, "from": "US.13491208655302741918", "to": "+13125559876", "channel": "whatsapp", "messageType": "text", "text": "Hi, I saw your listing", "profileName": "Sheena", "providerTimestamp": 1705312199000 } } ``` > [!WARNING] > > If your integration parses `data.from` as a phone number (regex for `+`, phone-number libraries, CRM lookups by E.164), it will break the first time a username contact writes in. Treat `from` as an opaque recipient identifier: it is either an E.164 phone number or a BSUID starting with a two-letter region prefix like `US.`. `profileName` still carries the user's display name when available, so your inbox UI has something human to show. `conversationId` is always the thread the message was filed in, including on the message that ties a BSUID to a phone number: that webhook names the thread that remains once the two are joined. ## `from` can change for the same person `data.from` is the phone number when WhatsApp includes it in the message, and the BSUID when it does not. WhatsApp decides that, not Zavu. As integrators have reported WhatsApp's BSUID rollout, the phone number is typically included when your business number messaged or called that person, or received a message or call from their number, in the last 30 days, or when the person is in your business portfolio's Meta contact book. Check Meta's documentation for the current rule. It can also stop being included again later. So the same person can write to you first as `US.13491208655302741918` and later as `+13125551234`, and back. Do not use `from` as the key for a person. Key on one of: * `data.contactId`: the Zavu contact. It does not change when the identifier does, except when the switch reveals two contacts for one person (see below). * `data.whatsapp.bsuid`: the BSUID, present on every WhatsApp `message.inbound` once Zavu knows it. When Zavu ties a BSUID to a phone number, it sends a `contact.identity_linked` event, once per link: ```json theme={null} { "id": "evt_1705312300000_def456", "type": "contact.identity_linked", "timestamp": 1705312300000, "senderId": "snd_abc123", "projectId": "prj_xyz789", "data": { "contactId": "jd7x2k3m4n5p6q7r8s9t0abc", "bsuid": "US.13491208655302741918", "phoneNumber": "+13125551234", "reason": "provider_reported" } } ``` | `reason` | Meaning | | - | - | | `provider_reported` | WhatsApp sent the phone number together with the BSUID on an inbound message. | | `contact_shared` | The person shared their number in reply to a [`request_contact_info`](/guides/whatsapp/messages/request-contact-info) message. | | `phone_changed` | The person changed their WhatsApp number. `previousPhoneNumber` carries the old one. Sent for contacts known only by phone too, with `bsuid` set to `null`. | | `bsuid_changed` | WhatsApp reported a new BSUID for the same number. `previousBsuid` carries the old one. | If two contacts turned out to be the same person, they are merged: `contactId` is the one that remains and `mergedContactId` is the one that went away, so you can move what you stored under it. When the remaining contact keeps a BSUID of its own, `previousBsuid` carries the one you knew. Subscribe to the event on the sender's webhook. To look a contact up by BSUID, call `GET /v1/contacts?bsuid=US.13491208655302741918`; contacts carry the same `whatsapp` object. ## Replying and sending to a BSUID Pass the BSUID in `to`, exactly where a phone number would go. Zavu detects the format and routes it correctly on the WhatsApp API. ```typescript TypeScript theme={null} const message = await zavu.messages.send({ to: "US.13491208655302741918", channel: "whatsapp", text: "Thanks for reaching out! How can we help?" }); ``` ```python Python theme={null} message = zavu.messages.send( to="US.13491208655302741918", channel="whatsapp", text="Thanks for reaching out! How can we help?" ) ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer zv_live_xxx" \ -H "Content-Type: application/json" \ -d '{ "to": "US.13491208655302741918", "channel": "whatsapp", "text": "Thanks for reaching out! How can we help?" }' ``` All WhatsApp message types work, including media, interactive messages and templates. The [24-hour conversation window](/guides/whatsapp/overview) rules are unchanged. Two exceptions: | Limitation | Behavior | | - | - | | Authentication templates with one-tap, zero-tap or copy-code buttons | Rejected by WhatsApp (error `131062`). These flows verify a phone number, so they need one. Send to the contact's phone number instead. | | Broadcasts | BSUID and `@username` recipients are rejected with a clear error. Broadcasts require phone numbers. | ## Recovering the phone number If your workflow eventually needs the real phone number (calls, SMS fallback, identity verification), ask for it in the conversation. WhatsApp provides a dedicated interactive message for this: [`request_contact_info`](/guides/whatsapp/messages/request-contact-info). It renders a fixed **Share Contact Info** button; when the user taps it and confirms, their phone number arrives as a regular inbound `contact` message. Zavu then links the shared phone number to the BSUID contact automatically and sends `contact.identity_linked` with `reason: "contact_shared"`: the contact record gains a `phoneNumber`, and both identifiers resolve to the same contact and conversation from that point on. This is not the only way a number gets linked: see [`from` can change for the same person](#from-can-change-for-the-same-person). You can also add a `request_contact_info` button to a [template](/guides/whatsapp/templates/overview#button-types) to ask outside the 24-hour window. ## Contacts in the dashboard A contact created from a BSUID-only inbound has no phone number until Zavu learns one, either because WhatsApp includes it in a later message or because the user shares it. The dashboard contact list and inbox display the username or BSUID instead, and flag conversations where a phone number was linked from a username contact. In the API, expect `phoneNumber` to be absent on these contacts, and `whatsapp.bsuid` to be present. Key on `contactId` of the inbound events to correlate messages with your own records, not on `from`, which can switch between the BSUID and the phone number. ## Your own business username Your WABA can reserve a business username so customers can find and message you without knowing your number. Manage it from the dashboard under **Accounts → WhatsApp → your WABA**: the username section shows the current username, its status (reserved, approved, deleted) and lets you set or change it. Status changes arrive automatically; there is nothing to poll. > [!NOTE] > > Username adoption is rolling out gradually on WhatsApp's side. Your integration should handle BSUIDs today even if you have not seen one yet: any user can adopt a username at any time, and their next message to you will carry a BSUID. # Introduction to Zavu Source: https://docs.zavu.dev/introduction Build and deploy AI agents that answer on WhatsApp, SMS, voice, email, Telegram and Messenger. One API, one webhook contract, one bill. ## What Zavu is Zavu is the communication layer for humans and agents. You get two things, and they are the same platform: * **An API for messages.** Send and receive on WhatsApp, SMS, voice, email, Telegram, Instagram and Messenger through one integration. * **A runtime for agents.** Declare an agent in TypeScript, deploy it with one command, and it answers on those same channels. Including phone calls. Most teams start with one and end up using both. The agent that answers a WhatsApp thread is the same object that answers the phone, and the message you send by hand goes out the same pipe. ## Three ways to build There is no wrong entry point. Pick the one that matches where your code lives, or skip the code entirely. > [card] [Deploy on our infra](/quickstart) > > Write `defineAgent` and `defineTool` in a file, run `npx zavudev deploy`. > Zavu runs the code, holds the conversation, and calls your tools. > [card] [Call our API from yours](/quickstart-messaging) > > Keep your stack. Use the REST API or an SDK to send, receive, and drive > conversations from your own backend. > [card] [Use an integration](/guides/integrations/overview) > > n8n and more. Connect an account, switch on what you need, write nothing. ## Ship an agent ```ts theme={null} import { defineAgent, defineTool } from "@zavudev/functions" defineAgent({ senderId: process.env.SENDER_ID!, name: "Bella", provider: "zavu", model: "openai/gpt-4o-mini", prompt: "You are Bella, host at the pizzeria. Keep it short.", channels: ["whatsapp"], }) defineTool({ name: "check_availability", description: "Get free reservation slots for a date and party size.", parameters: { type: "object", properties: { date: { type: "string" }, partySize: { type: "number" }, }, required: ["date", "partySize"], }, handler: async ({ date, partySize }) => { // your real booking logic: DB, POS, external API, whatever return { available: true, slots: ["19:00", "21:00"] } }, }) ``` ```bash theme={null} npx zavudev deploy ``` Your code is the source of truth. Every deploy reconciles the live agent to match what the file declares, so an agent you delete from the code is deleted for real. See [How agents work](/concepts/agent-architecture). Not starting from scratch? `npx zavudev agents pull fermi` drops a ready-made agent into your project as editable code. Seven of them ship today, for sales, support, and front desk. See [Factory agents](/guides/functions/factory-agents). ## Send a message ```typescript theme={null} import Zavudev from '@zavudev/sdk'; const zavu = new Zavudev(); await zavu.messages.send({ to: "+14155551234", text: "Your order #1234 has shipped!" }); ``` No channel specified means `auto`: Zavu scores the channels your sender can reach the recipient on and sends through the one that wins on cost and deliverability, with fallback if it fails. See [Smart routing](/guides/sending-messages/smart-routing). > [!NOTE] > > This page and the SDKs use API v1. [API v2](/api-v2/overview) is a smaller > version of the same API: you send from an address instead of a sender, and one > webhook covers the project. Both are supported, and you can mix them. ## Channels | Channel | Best for | Agents | | - | - | - | | **WhatsApp** | Rich media, templates, high engagement | Yes | | **SMS** | Universal reach, alerts, OTP, no internet required | Yes | | **Voice** | Two-way phone calls, text-to-speech alerts | Yes | | **Email** | Transactional and marketing mail, attachments, replies | Yes | | **Telegram** | Bot messaging, large file sharing | Yes | | **Messenger** | Facebook Page and Marketplace conversations | Yes | | **Instagram** | Direct messages on a Business account | Yes | Every channel shares one send contract, one set of [webhook events](/guides/receiving-messages/events), and one bill. ## Build with your coding agent Zavu is designed to be operated by the agent you already code with. Give it the skills and it writes correct Zavu code on the first try, instead of guessing at endpoints. ```bash theme={null} npx skills add zavudev/zavu-skills ``` Twelve skills cover agents, functions, voice, channels, webhooks, templates, broadcasts, contacts and phone numbers. For direct API execution, add the [MCP server](/tools/mcp-server). They compose: skills decide what to do, MCP does it. ## Next steps > [card] [Agent quickstart](/quickstart) > > Deploy an agent that answers a real channel, in about ten minutes > [card] [Messaging quickstart](/quickstart-messaging) > > Send your first message from your own backend > [card] [How agents work](/concepts/agent-architecture) > > From TypeScript on your machine to an agent answering a phone call > [card] [CLI](/guides/cli/overview) > > Every resource in the API, from your shell and your CI > [card] [Authentication](/authentication) > > API keys, environments, and the sender header > [card] [API reference](/api-reference) > > Every endpoint, with a live playground > [card] [Integrations](/guides/integrations/overview) > > n8n, Vercel and more, with no code to write # Quickstart — Deploy Your First Agent Source: https://docs.zavu.dev/quickstart Pull a ready-made AI agent into your codebase, deploy it with one command, and have it answering a real channel in about ten minutes. By the end of this page an agent you own, as editable TypeScript, is answering messages on one of your channels. > [!NOTE] > > Prefer to send messages from your own backend instead of deploying code here? > Start with the [messaging quickstart](/quickstart-messaging). ## 1. Install the CLI ```sh npx (recommended) theme={null} # No install step. Always runs the current version. npx zavudev@latest --version ``` ```sh Global install theme={null} npm install -g zavudev zavudev --version ``` ```sh Standalone binary theme={null} # Download the binary for your platform from # https://github.com/zavudev/zavu-cli/releases # Then: chmod +x ./zavu-macos-arm64 sudo mv ./zavu-macos-arm64 /usr/local/bin/zavudev ``` > [!TIP] > > Working with a coding agent? Install the > [skills](/tools/coding-agent-skills) first and it will know every command on > this page, plus `defineAgent` and `defineTool`: > > ```sh theme={null} > npx skills add zavudev/zavu-skills > ``` ## 2. Log in ```sh theme={null} npx zavudev login ``` Your browser opens, you sign in, pick the project this agent will live in, and authorize. The CLI stores the key in `~/.zavu/credentials.json` with mode `0600`. Confirm which project you are pointed at: ```sh theme={null} npx zavudev whoami ``` ## 3. Scaffold an agent `agents init` is the guided path. It picks a factory agent, finds or creates the sender it runs on, writes the files, and registers the function. ```sh theme={null} npx zavudev agents init ``` To see what you can start from first: ```sh theme={null} npx zavudev agents catalog ``` Seven factory agents ship today, across sales, support and front desk. Some are voice agents that answer the phone; all of them also run on text channels. > [accordion] Pull a specific agent > > ```sh theme={null} > npx zavudev agents pull fermi --sender > ``` > > Scaffolds into `./fermi`, registers the function, and sets the `SENDER_ID` > secret from the sender you passed. Use `--dir` to scaffold elsewhere. > [accordion] Start from an empty function instead > > ```sh theme={null} > npx zavudev fn init > ``` > > Gives you a bare `index.ts` to write `defineAgent` yourself. The > [Functions quickstart](/guides/functions/quickstart) walks that path in > full. > [accordion] I have no sender yet > > An agent answers on a sender, and a sender needs at least one channel. > The fastest channel to turn on needs no phone number and no external > account: > > ```sh theme={null} > npx zavudev senders create --data '{"name":"Agent","enableSmsOneway":true}' > ``` > > Recipients cannot reply on one-way SMS, so use it to verify the deploy > works, then [add a real channel](/guides/senders/adding-channels). > `npx zavudev agents init --buy-number` buys a number and creates the > sender for you instead. ## 4. Install dependencies ```sh theme={null} cd fermi npm install ``` Deploy works without this, but every local check does not: `fn invoke`, `tsc --noEmit`, and your editor all need the dev dependencies installed. ## 5. Look at what you got The scaffold is one file, and it is the whole agent. (You can split it up later — imports between your files are resolved on deploy.) ```ts index.ts theme={null} import { defineAgent, defineTool } from "@zavudev/functions" defineAgent({ senderId: process.env.SENDER_ID!, name: "Fermi", provider: "zavu", model: "openai/gpt-4o-mini", prompt: "You are Fermi, a qualification agent...", channels: ["voice", "whatsapp"], }) defineTool({ name: "lookup_account", description: "Find an existing account by phone number.", parameters: { type: "object", properties: { phone: { type: "string" } }, required: ["phone"], }, handler: async ({ phone }) => { // replace with your real lookup return { found: false } }, }) ``` > [!TIP] > > `provider: "zavu"` is our managed AI gateway. No API key of your own required; > model usage is billed from your Zavu balance. Bring your own key by setting > `provider` to `openai`, `anthropic`, `google` or `mistral`. Any secret the agent needs beyond `SENDER_ID` is listed by `agents pull` when it finishes. Set each one before deploying: ```sh theme={null} npx zavudev fn secrets set CALENDAR_WEBHOOK_URL "https://..." ``` ## 6. Try a tool without deploying ```sh theme={null} npx zavudev fn invoke --tool lookup_account --args '{"phone":"+14155551234"}' ``` This runs the handler on your machine, with no cloud round-trip and nothing charged. Use it to get the business logic right before anyone can talk to the agent. ## 7. Deploy ```sh theme={null} npx zavudev deploy ``` ``` › status: bundling › status: uploading › status: publishing › status: active ✓ Deployed in 14s Agents synced: + Fermi Tools synced: + lookup_account ``` > [!WARNING] > > Read the lines **above** the checkmark. Deploy prints its warnings before the > success line, and they cover the cases where a green deploy did not do what it > looks like: tools attached to an agent whose channels will never call them, or > a second agent landing on a sender that already has one. Your code is now the source of truth. Every deploy reconciles the live agent to match the file, so a `defineAgent` you delete deletes the agent. ## 8. Talk to it Message the sender's number from your phone, or call it if the agent has voice enabled. To check the reply without touching a real channel: ```sh theme={null} npx zavudev agents test --sender "$SENDER_ID" --message "what do you do?" ``` > [!NOTE] > > A dry run never executes tools, because a test must not cause real side > effects. It tells you so in its warnings. Live conversations on every channel > do call them. ## 9. Watch it run ```sh theme={null} # What the agent did: tools called, tokens, cost, latency npx zavudev agents executions --sender "$SENDER_ID" ``` ```sh theme={null} # Your handlers' console output, live npx zavudev fn logs --tail ``` ```sh theme={null} # Every message in and out npx zavudev messages list --limit 10 ``` ## Next steps > [card] [How agents work](/concepts/agent-architecture) > > What a deploy actually does, and what happens when a message arrives > [card] [Defining agents](/guides/functions/defining-agents) > > Every field of `defineAgent`, including voice > [card] [Defining tools](/guides/functions/defining-tools) > > Give the agent access to your systems > [card] [Knowledge bases](/guides/ai-agents/knowledge-base) > > Ground the answers in your own documents > [card] [Voice agents](/guides/voice-agents/overview) > > Answer and place phone calls with the same agent > [card] [Bring your framework](/guides/frameworks/overview) > > Deploy an eve project without restructuring it # Messaging Quickstart — Send Your First Message Source: https://docs.zavu.dev/quickstart-messaging Send your first SMS, WhatsApp or Email message with Zavu in under 5 minutes. Step-by-step guide with code samples in TypeScript, Python, Go, PHP and Ruby. Follow these steps to send messages from your own backend, with Zavu as the transport. > [!NOTE] > > Want Zavu to run the conversation instead of you? The > [agent quickstart](/quickstart) deploys an AI agent that answers these same > channels, including phone calls. > [!NOTE] > > This quickstart uses API v1 and its senders. Prefer to send from an address, > with one webhook for the project? See [API v2](/api-v2/overview). v1 stays > supported. ## 1. Create an Account Sign up at [dashboard.zavu.dev](https://dashboard.zavu.dev). You'll be prompted to create your first team and project. > [!NOTE] > > Projects isolate your messaging by environment (development, staging, production) or by application. ## 2. Create a Sender A [**Sender**](/concepts/senders) is your messaging identity — it defines who sends messages and which channels are available. Go to **Senders** in the sidebar and create your first sender. 1. Click **Add Sender** 2. Give it a name (e.g., "Notifications", "Support") 3. Assign a phone number (see step 3) or configure channels directly 4. Set as default sender > [!TIP] > > Your [**default sender**](/concepts/senders#default-sender) is used automatically when you send a message without specifying one. You can override it with the [`Zavu-Sender` header](/authentication#zavu-sender-header). ## 3. Configure Channels Each sender can have multiple channels. The channels you need depend on your use case: | Channel | Requirements | What It's For | | - | - | - | | **[SMS](/guides/sending-messages/sms)** | [Phone number](/concepts/phone-numbers) with SMS capability | Universal reach, alerts, OTP | | **[WhatsApp](/guides/whatsapp/overview)** | Phone number + [WABA connection](/guides/whatsapp/connect-whatsapp) | Rich messaging, cost-effective | | **[Email](/guides/sending-messages/email)** | [Verified domain](/guides/email/setup) (DKIM records) | Transactional emails, newsletters | | **[Telegram](/guides/telegram/overview)** | Bot token from @BotFather | Bot messaging, tech audiences | | **Instagram** | Meta App + Business Account | Social messaging | | **Voice** | Phone number with voice capability | Text-to-speech calls, verification | ### Channels that need a phone number **SMS**, **WhatsApp**, and **Voice** require a [phone number](/concepts/phone-numbers). You can [purchase one](/guides/phone-numbers/purchasing) directly in Zavu: **1. Go to Phone Numbers** Navigate to **Phone Numbers** in the sidebar. **2. Search and purchase** Search by country or area code and purchase a number. **3. Assign to your sender** Assign the phone number to your sender. SMS and Voice are automatically enabled based on the number's capabilities. For **WhatsApp**, you also need to [connect a WhatsApp Business Account](/guides/whatsapp/connect-whatsapp) (WABA) through Meta's embedded signup in the dashboard. ### Channels without a phone number **Email**, **Telegram**, and **Instagram** each have their own setup: * **Email**: [Add and verify your domain](/guides/email/setup) with DKIM records in **Email Domains** * **Telegram**: [Create a bot](/guides/telegram/setup) via [@BotFather](https://t.me/BotFather) and paste the token in your sender's Channels tab * **Instagram**: Connect your Meta App and Instagram Business Account via the dashboard > [!NOTE] > > SMS and WhatsApp participate in [**smart routing**](/concepts/senders#smart-routing) and [**automatic fallback**](/concepts/senders#automatic-fallback). Other channels must be explicitly specified when sending. See [Adding Channels](/guides/senders/adding-channels) for detailed setup instructions. ## 4. Create an API Key Go to **Settings** → **API Keys** and generate your first key. See [Authentication](/authentication) for details on key types and security. 1. Click **Create API Key** 2. Give it a descriptive name 3. Copy and store the key securely > [!WARNING] > > Your API key will only be shown once. Store it in your environment variables: > > ```bash theme={null} > export ZAVUDEV_API_KEY="zv_live_your_api_key_here" > ``` ## 5. Send Your First Message Install the SDK for your language: ```bash TypeScript theme={null} npm add @zavudev/sdk # or: bun add @zavudev/sdk ``` ```bash Python theme={null} pip install zavudev # or: uv add zavudev ``` ```bash Ruby theme={null} gem install zavudev ``` ```bash Go theme={null} go get github.com/zavudev/sdk-go ``` ```bash PHP theme={null} composer require zavudev/sdk ``` > [!WARNING] > > **Server-side only** — The Zavu SDKs are designed for server-side environments (Node.js, Python, Ruby, Go, PHP, serverless functions). Never use them in browser/frontend code as this exposes your API key. See [Frontend Integration](/authentication#frontend-integration) for the proxy pattern. Then send your first message: ```typescript TypeScript theme={null} import Zavudev from '@zavudev/sdk'; const zavu = new Zavudev({ apiKey: process.env['ZAVUDEV_API_KEY'], // This is the default and can be omitted }); const result = await zavu.messages.send({ to: "+14155551234", text: "Hello from Zavu!", }); console.log("Message ID:", result.message.id); console.log("Status:", result.message.status); ``` ```python Python theme={null} import os from zavudev import Zavudev zavu = Zavudev( api_key=os.environ.get("ZAVUDEV_API_KEY"), # This is the default and can be omitted ) result = zavu.messages.send( to="+14155551234", text="Hello from Zavu!", ) print(f"Message ID: {result.message.id}") print(f"Status: {result.message.status}") ``` ```ruby Ruby theme={null} require "zavudev" client = Zavudev::Client.new(api_key: ENV["ZAVUDEV_API_KEY"]) result = client.messages.send_( to: "+14155551234", text: "Hello from Zavu!" ) puts "Message ID: #{result.message.id}" puts "Status: #{result.message.status}" ``` ```go Go theme={null} package main import ( "context" "fmt" "os" "github.com/zavudev/sdk-go" "github.com/zavudev/sdk-go/option" ) func main() { client := zavudev.NewClient(option.WithAPIKey(os.Getenv("ZAVUDEV_API_KEY"))) result, _ := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("+14155551234"), Text: zavudev.String("Hello from Zavu!"), }) fmt.Println("Message ID:", result.Message.ID) fmt.Println("Status:", result.Message.Status) } ``` ```php PHP theme={null} messages->send([ 'to' => '+14155551234', 'text' => 'Hello from Zavu!', ]); echo "Message ID: " . $result->message->id . "\n"; echo "Status: " . $result->message->status . "\n"; ``` ```bash cURL theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "+14155551234", "text": "Hello from Zavu!" }' ``` > [!TIP] > > **Success!** You should receive a `202 Accepted` response. Your default sender handles routing automatically — if both SMS and WhatsApp are configured, Zavu selects the optimal channel based on cost and deliverability. ### Sending on a specific channel To target a specific channel, pass the `channel` parameter: ```typescript TypeScript theme={null} // Send via WhatsApp await zavu.messages.send({ to: "+14155551234", text: "Hello via WhatsApp!", channel: "whatsapp", }); // Send via Email await zavu.messages.send({ to: "user@example.com", channel: "email", subject: "Hello from Zavu", text: "Your first email message!", }); // Send via Voice (text-to-speech) await zavu.messages.send({ to: "+14155551234", channel: "voice", text: "Your verification code is 1 2 3 4 5 6", }); ``` ```python Python theme={null} # Send via WhatsApp zavu.messages.send( to="+14155551234", text="Hello via WhatsApp!", channel="whatsapp", ) # Send via Email zavu.messages.send( to="user@example.com", channel="email", subject="Hello from Zavu", text="Your first email message!", ) # Send via Voice (text-to-speech) zavu.messages.send( to="+14155551234", channel="voice", text="Your verification code is 1 2 3 4 5 6", ) ``` ```ruby Ruby theme={null} # Send via WhatsApp client.messages.send_( to: "+14155551234", text: "Hello via WhatsApp!", channel: "whatsapp" ) # Send via Email client.messages.send_( to: "user@example.com", channel: "email", subject: "Hello from Zavu", text: "Your first email message!" ) # Send via Voice (text-to-speech) client.messages.send_( to: "+14155551234", channel: "voice", text: "Your verification code is 1 2 3 4 5 6" ) ``` ```go Go theme={null} // Send via WhatsApp client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("+14155551234"), Text: zavudev.String("Hello via WhatsApp!"), Channel: zavudev.String("whatsapp"), }) // Send via Email client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("user@example.com"), Channel: zavudev.String("email"), Subject: zavudev.String("Hello from Zavu"), Text: zavudev.String("Your first email message!"), }) // Send via Voice (text-to-speech) client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("+14155551234"), Channel: zavudev.String("voice"), Text: zavudev.String("Your verification code is 1 2 3 4 5 6"), }) ``` ```php PHP theme={null} messages->send([ 'to' => '+14155551234', 'text' => 'Hello via WhatsApp!', 'channel' => 'whatsapp', ]); // Send via Email $client->messages->send([ 'to' => 'user@example.com', 'channel' => 'email', 'subject' => 'Hello from Zavu', 'text' => 'Your first email message!', ]); // Send via Voice (text-to-speech) $client->messages->send([ 'to' => '+14155551234', 'channel' => 'voice', 'text' => 'Your verification code is 1 2 3 4 5 6', ]); ``` ### Response ```json theme={null} { "message": { "id": "jd70x0ms07pbfyknb2hj8akznn7whac3", "to": "+14155551234", "from": "+14155559876", "channel": "sms", "status": "queued", "messageType": "text", "text": "Hello from Zavu!", "createdAt": "2024-01-15T10:30:00.000Z" } } ``` ## 6. Configure Webhooks (Optional) To receive delivery status updates and incoming messages, configure a [webhook](/guides/receiving-messages/webhooks) on your sender. 1. Go to **Senders** and select your sender 2. Add a **Webhook URL** (must be HTTPS) 3. Select the events you want to receive [Webhook events](/guides/receiving-messages/events) include: * `message.queued` - Message accepted * `message.sent` - Message sent to carrier * `message.delivered` - Message delivered to recipient * `message.failed` - Message failed to deliver * `message.inbound` - Incoming message received * `conversation.new` - New conversation started ## Sending limits for new accounts A new account sends on every channel, to any destination, right away. There is nothing to verify first. What a new account has is a lower daily ceiling: 25 SMS, 5 calls and 100 conversational messages a day. Verifying your identity, adding a payment method, settling a deposit or subscribing raises it. Each is self-serve in the dashboard; no support ticket is needed. Two more things apply to US and Canadian SMS, and to every message: * SMS to a +1 number needs an approved 10DLC campaign, which carriers require. Without one, `403 ten_dlc_required`. * Messages on `sms`, `sms_oneway`, `email` and `voice` are read before they are sent. One can be held briefly for review — it stays `queued` — or refused, in which case it fails with `RISK_BLOCKED`, `RISK_REJECTED` or `RISK_REVIEW_EXPIRED` in `errorCode`. See [Sending limits](/concepts/sending-limits) for the numbers and the error codes. ## Next Steps > [card] [Sending Messages](/guides/sending-messages/easy-way) > > Learn different ways to send messages > [card] [Adding Channels](/guides/senders/adding-channels) > > Configure SMS, WhatsApp, Email, Telegram, and more > [card] [WhatsApp](/guides/whatsapp/overview) > > Send rich WhatsApp messages with media and templates > [card] [API Reference](/api-reference) > > Explore all endpoints # Broadcasts Source: https://docs.zavu.dev/sdks/go/broadcasts Create, schedule and track bulk message broadcasts via the Zavu Go SDK. Add contacts, send to thousands and monitor delivery progress. Broadcasts allow you to send messages to multiple recipients in a single campaign. ## Create Broadcast ```go theme={null} result, err := client.Broadcasts.New(context.TODO(), zavudev.BroadcastCreateParams{ Name: zavudev.String("Black Friday Sale"), Channel: zavudev.String("sms"), Text: zavudev.String("Hi {{name}}, check out our Black Friday deals! Use code FRIDAY20 for 20% off."), }) if err != nil { panic(err) } fmt.Println(result.Broadcast.ID) // brd_xxx fmt.Println(result.Broadcast.Status) // draft ``` ### WhatsApp Template Broadcast ```go theme={null} result, err := client.Broadcasts.New(context.TODO(), zavudev.BroadcastCreateParams{ Name: zavudev.String("Order Confirmation Campaign"), Channel: zavudev.String("whatsapp"), MessageType: zavudev.String("template"), Content: &zavudev.BroadcastContent{ TemplateID: zavudev.String("tpl_abc123"), }, }) ``` ### Email Broadcast ```go theme={null} result, err := client.Broadcasts.New(context.TODO(), zavudev.BroadcastCreateParams{ Name: zavudev.String("Newsletter"), Channel: zavudev.String("email"), EmailSubject: zavudev.String("Your Weekly Update"), Text: zavudev.String("Hi {{name}}, here's what's new this week..."), }) ``` ## Get Broadcast ```go theme={null} result, err := client.Broadcasts.Get(context.TODO(), "brd_abc123") if err != nil { panic(err) } fmt.Println(result.Broadcast.Name) fmt.Println(result.Broadcast.Status) fmt.Println(result.Broadcast.TotalContacts) ``` ## List Broadcasts ```go theme={null} result, err := client.Broadcasts.List(context.TODO(), zavudev.BroadcastListParams{}) if err != nil { panic(err) } for _, broadcast := range result.Items { fmt.Println(broadcast.ID, broadcast.Name, broadcast.Status) } // Filter by status result, err := client.Broadcasts.List(context.TODO(), zavudev.BroadcastListParams{ Status: zavudev.String("completed"), Limit: zavudev.Int(50), }) ``` ## Update Broadcast Update a broadcast in draft status: ```go theme={null} result, err := client.Broadcasts.Update(context.TODO(), "brd_abc123", zavudev.BroadcastUpdateParams{ Name: zavudev.String("Updated Campaign Name"), Text: zavudev.String("New message content with {{name}}"), }) ``` ## Add Contacts Add contacts to a broadcast (max 1000 per request): ```go theme={null} result, err := client.Broadcasts.Contacts.Add(context.TODO(), "brd_abc123", zavudev.BroadcastContactsAddParams{ Contacts: []zavudev.BroadcastContactInput{ { Recipient: "+14155551234", TemplateVariables: map[string]string{ "name": "John", "order_id": "ORD-001", }, }, { Recipient: "+14155555678", TemplateVariables: map[string]string{ "name": "Jane", "order_id": "ORD-002", }, }, }, }) if err != nil { panic(err) } fmt.Println(result.Added) // 2 fmt.Println(result.Duplicates) // 0 fmt.Println(result.Invalid) // 0 ``` ## List Broadcast Contacts ```go theme={null} result, err := client.Broadcasts.Contacts.List(context.TODO(), "brd_abc123", zavudev.BroadcastContactsListParams{ Status: zavudev.String("pending"), Limit: zavudev.Int(100), }) if err != nil { panic(err) } for _, contact := range result.Items { fmt.Println(contact.Recipient, contact.Status) } ``` ## Remove Contact ```go theme={null} err := client.Broadcasts.Contacts.Remove(context.TODO(), "brd_abc123", "bcon_xyz789") if err != nil { panic(err) } ``` ## Send Broadcast Start sending immediately: ```go theme={null} result, err := client.Broadcasts.Send(context.TODO(), "brd_abc123", zavudev.BroadcastSendParams{}) if err != nil { panic(err) } fmt.Println(result.Broadcast.Status) // sending ``` Schedule for later: ```go theme={null} result, err := client.Broadcasts.Send(context.TODO(), "brd_abc123", zavudev.BroadcastSendParams{ ScheduledAt: zavudev.String("2024-01-15T10:00:00Z"), }) if err != nil { panic(err) } fmt.Println(result.Broadcast.Status) // scheduled ``` ## Reschedule Broadcast Change the scheduled time for a broadcast in `scheduled` status: ```go theme={null} result, err := client.Broadcasts.Reschedule(context.TODO(), "brd_abc123", zavudev.BroadcastRescheduleParams{ ScheduledAt: zavudev.String("2024-01-16T14:00:00Z"), }) if err != nil { panic(err) } fmt.Println(result.Broadcast.ScheduledAt) // 2024-01-16T14:00:00.000Z ``` > [!NOTE] > > You can only reschedule broadcasts that are in `scheduled` status. The new time must be in the future. ## Get Progress Monitor broadcast progress in real-time: ```go theme={null} result, err := client.Broadcasts.Progress(context.TODO(), "brd_abc123") if err != nil { panic(err) } fmt.Println(result.Status) // sending fmt.Println(result.Total) // 5000 fmt.Println(result.Delivered) // 2350 fmt.Println(result.Failed) // 50 fmt.Println(result.PercentComplete) // 48.0 fmt.Println(result.EstimatedCompletionAt) ``` ## Cancel Broadcast ```go theme={null} result, err := client.Broadcasts.Cancel(context.TODO(), "brd_abc123") if err != nil { panic(err) } fmt.Println(result.Broadcast.Status) // cancelled ``` > [!WARNING] > > Cancelling a broadcast will skip pending contacts, but messages already queued may still be delivered. ## Delete Broadcast Delete a broadcast in draft status: ```go theme={null} err := client.Broadcasts.Delete(context.TODO(), "brd_abc123") if err != nil { panic(err) } ``` # Contacts Source: https://docs.zavu.dev/sdks/go/contacts Create, list, retrieve and update contacts via the Zavu Go SDK. Manage profile metadata, channels and merge suggestions from your code. Contacts are automatically created when you send messages. You can retrieve and update contact information. ## Get Contact ```go theme={null} result, err := client.Contacts.Get(context.TODO(), "con_abc123") if err != nil { panic(err) } fmt.Println(result.ID) fmt.Println(result.PhoneNumber) fmt.Println(result.CountryCode) fmt.Println(result.AvailableChannels) fmt.Println(result.DefaultChannel) ``` ## Get Contact by Phone ```go theme={null} result, err := client.Contacts.GetByPhone(context.TODO(), "+14155551234") if err != nil { panic(err) } fmt.Println(result.ID) fmt.Println(result.AvailableChannels) // ["sms", "whatsapp"] ``` ## Update Contact ```go theme={null} result, err := client.Contacts.Update(context.TODO(), "con_abc123", zavudev.ContactUpdateParams{ DefaultChannel: zavudev.String("whatsapp"), Metadata: map[string]string{ "name": "John Doe", "tier": "premium", }, }) ``` ## List Contacts ```go theme={null} result, err := client.Contacts.List(context.TODO(), zavudev.ContactListParams{ Limit: zavudev.Int(50), }) if err != nil { panic(err) } for _, contact := range result.Items { fmt.Println(contact.ID, contact.PhoneNumber) } // Filter by phone number result, err := client.Contacts.List(context.TODO(), zavudev.ContactListParams{ PhoneNumber: zavudev.String("+1415"), }) // Pagination var cursor *string for { result, err := client.Contacts.List(context.TODO(), zavudev.ContactListParams{ Cursor: cursor, Limit: zavudev.Int(50), }) if err != nil { panic(err) } for _, contact := range result.Items { fmt.Println(contact.ID) } if result.NextCursor == nil { break } cursor = result.NextCursor } ``` ## Phone Introspection Validate a phone number and check available channels: ```go theme={null} result, err := client.Introspect.Phone(context.TODO(), zavudev.PhoneIntrospectionParams{ PhoneNumber: zavudev.String("+14155551234"), }) if err != nil { panic(err) } fmt.Println(result.ValidNumber) // true fmt.Println(result.CountryCode) // "US" fmt.Println(result.NationalFormat) // "(415) 555-1234" fmt.Println(result.LineType) // "mobile" fmt.Println(result.AvailableChannels) // ["sms", "whatsapp"] if result.Carrier != nil { fmt.Println(result.Carrier.Name) // "Verizon Wireless" } ``` # Invitations Source: https://docs.zavu.dev/sdks/go/invitations Partner invitations for client WhatsApp Business onboarding. Not yet generated in the Go SDK: use the REST API or the CLI. > [!WARNING] > > **Not generated in the Go SDK yet.** `sdk-go` does not expose an `invitations` > resource today, so there is nothing to call. The endpoints exist and are stable: > use the REST API below, the [CLI](/guides/cli/overview), or the > [TypeScript SDK](/sdks/typescript/invitations), which does cover it. See the > [coverage table](/sdks/overview#coverage). Partner invitations generate a link a client opens to connect their own WhatsApp Business Account, or a Facebook Page, into your project. One invitation connects one channel. ## Create an invitation ```bash theme={null} curl -X POST https://api.zavu.dev/v1/invitations \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "clientName": "Acme Corp", "clientEmail": "contact@acme.com", "connectionType": "whatsapp_waba", "expiresInDays": 14 }' ``` The response carries `url`, the link you share with the client, and `token`. ```bash CLI theme={null} npx zavudev invitations create --data '{"clientName":"Acme Corp"}' ``` ## Retrieve an invitation ```bash theme={null} curl https://api.zavu.dev/v1/invitations/inv_abc123 \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` Poll `status` (`pending`, `in_progress`, `completed`, `expired`, `cancelled`, `failed`), or subscribe to the `invitation.status_changed` webhook instead of polling. A `failed` invitation is still usable: the same link can be retried. ## List invitations ```bash theme={null} curl "https://api.zavu.dev/v1/invitations?status=pending&limit=50" \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` ## Cancel an invitation ```bash theme={null} curl -X POST https://api.zavu.dev/v1/invitations/inv_abc123/cancel \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` ## After completion When the client finishes, the invitation moves to `completed` and carries `senderId` plus `connectedAccount` (the WhatsApp number or Facebook Page that was linked). That sender is now yours to send from. > [card] [Partner invitations guide](/guides/partner-invitations/api) > > The full flow, webhooks, and failure codes > [card] [API reference](/api-reference/create-invitation) > > Every field on the endpoint # Send Messages with the Go SDK Source: https://docs.zavu.dev/sdks/go/messages Send and manage SMS, WhatsApp, Email, and Voice messages with the Zavu Go SDK. Production-ready, with helpers for retries, delivery tracking and ## Send a Message ```go theme={null} result, err := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("+14155551234"), Text: zavudev.String("Your verification code is 123456"), Channel: zavudev.String("sms"), // optional: sms, whatsapp, email Metadata: map[string]string{ "userId": "user_123", }, IdempotencyKey: zavudev.String("unique-key"), // optional: prevent duplicates }) if err != nil { panic(err) } fmt.Println(result.Message.ID) // msg_xxx fmt.Println(result.Message.Status) // queued ``` ## Send with Sender Specify which sender profile to use: ```go theme={null} result, err := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("+14155551234"), Text: zavudev.String("Hello!"), ZavuSender: zavudev.String("snd_abc123"), }) ``` ## Send with Template Send a WhatsApp template message: ```go theme={null} result, err := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("+14155551234"), MessageType: zavudev.String("template"), Content: &zavudev.MessageContent{ TemplateID: zavudev.String("tpl_abc123"), TemplateVariables: map[string]string{ "1": "John", "2": "12345", }, }, }) ``` ## Send WhatsApp Media ### Image ```go theme={null} result, err := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("+14155551234"), MessageType: zavudev.String("image"), Text: zavudev.String("Check out this product!"), Content: &zavudev.MessageContent{ MediaURL: zavudev.String("https://example.com/image.jpg"), }, }) ``` ### Document ```go theme={null} result, err := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("+14155551234"), MessageType: zavudev.String("document"), Content: &zavudev.MessageContent{ MediaURL: zavudev.String("https://example.com/invoice.pdf"), Filename: zavudev.String("invoice.pdf"), }, }) ``` ### Video ```go theme={null} result, err := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("+14155551234"), MessageType: zavudev.String("video"), Text: zavudev.String("Watch this!"), Content: &zavudev.MessageContent{ MediaURL: zavudev.String("https://example.com/video.mp4"), }, }) ``` ## Send Interactive Messages ### Buttons ```go theme={null} result, err := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("+14155551234"), MessageType: zavudev.String("buttons"), Text: zavudev.String("How would you rate your experience?"), Content: &zavudev.MessageContent{ Buttons: []zavudev.InteractiveButton{ {ID: "great", Title: "Great!"}, {ID: "okay", Title: "It was okay"}, {ID: "poor", Title: "Not good"}, }, }, }) ``` ### List ```go theme={null} result, err := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("+14155551234"), MessageType: zavudev.String("list"), Text: zavudev.String("Select an option:"), Content: &zavudev.MessageContent{ ListButton: zavudev.String("View Options"), Sections: []zavudev.ListSection{ { Title: "Products", Rows: []zavudev.ListRow{ {ID: "prod_1", Title: "Product A", Description: zavudev.String("$10.00")}, {ID: "prod_2", Title: "Product B", Description: zavudev.String("$20.00")}, }, }, }, }, }) ``` ## Send Email ```go theme={null} result, err := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("user@example.com"), Channel: zavudev.String("email"), Subject: zavudev.String("Your order has shipped"), Text: zavudev.String("Hi John, your order #12345 has shipped."), HTMLBody: zavudev.String("

Order Shipped

Your order #12345 has shipped.

"), ReplyTo: zavudev.String("support@example.com"), }) ``` ## Send Email with Attachments ```go theme={null} import ( "encoding/base64" "os" ) data, _ := os.ReadFile("invoice.pdf") content := base64.StdEncoding.EncodeToString(data) result, err := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("user@example.com"), Channel: zavudev.String("email"), Subject: zavudev.String("Your invoice"), Text: zavudev.String("Please find your invoice attached."), Attachments: []zavudev.EmailAttachmentInput{ { Filename: "invoice.pdf", Content: zavudev.String(content), ContentType: zavudev.String("application/pdf"), }, }, }) ``` ### Attachment from URL ```go theme={null} result, err := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("user@example.com"), Channel: zavudev.String("email"), Subject: zavudev.String("Your report"), Text: zavudev.String("Report attached."), Attachments: []zavudev.EmailAttachmentInput{ { Filename: "report.pdf", Path: zavudev.String("https://your-server.com/reports/report.pdf"), }, }, }) ``` ## Get Message Status ```go theme={null} result, err := client.Messages.Get(context.TODO(), "msg_abc123") if err != nil { panic(err) } fmt.Println(result.Message.Status) // queued, sending, sent, delivered, failed ``` ## List Messages ```go theme={null} // List all messages result, err := client.Messages.List(context.TODO(), zavudev.MessageListParams{}) // With filters result, err := client.Messages.List(context.TODO(), zavudev.MessageListParams{ Status: zavudev.String("delivered"), Channel: zavudev.String("sms"), Limit: zavudev.Int(100), }) // Pagination var cursor *string for { result, err := client.Messages.List(context.TODO(), zavudev.MessageListParams{ Cursor: cursor, Limit: zavudev.Int(50), }) if err != nil { panic(err) } for _, message := range result.Items { fmt.Println(message.ID) } if result.NextCursor == nil { break } cursor = result.NextCursor } ``` ## Send Reaction React to a WhatsApp message: ```go theme={null} result, err := client.Messages.React(context.TODO(), "msg_abc123", zavudev.ReactionParams{ Emoji: zavudev.String("\U0001F44D"), }) ``` # Go SDK Overview Source: https://docs.zavu.dev/sdks/go/overview Official Zavu SDK for Go. Send SMS, WhatsApp, Email and Voice messages with idiomatic Go errors, context support, retries and idempotency. > [!WARNING] > > **Server-side only** — This SDK is designed for server-side environments (Go servers, CLI tools). Never use it in browser/frontend code as this exposes your API key. See [Frontend Integration](/authentication#frontend-integration) for the proxy pattern. > [!NOTE] > > **Coverage.** The Go SDK does not generate invitations, URL verification, sub-accounts, balance, account, functions and 10DLC yet, > and no SDK generates voice calls. Those endpoints are still reachable: > use the [CLI](/guides/cli/overview) or the REST API. See the > [coverage table](/sdks/overview#coverage). The official Zavu SDK for Go provides a strongly-typed, idiomatic Go interface to the Zavu API. ## Installation ```bash theme={null} go get github.com/zavudev/sdk-go ``` ## Requirements * Go 1.21 or higher ## Quick Start ```go theme={null} package main import ( "context" "fmt" "os" "github.com/zavudev/sdk-go" "github.com/zavudev/sdk-go/option" ) func main() { client := zavudev.NewClient( option.WithAPIKey(os.Getenv("ZAVUDEV_API_KEY")), ) result, err := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("+14155551234"), Text: zavudev.String("Hello from Zavu!"), }) if err != nil { panic(err) } fmt.Println("Message sent:", result.Message.ID) } ``` ## Configuration ### Initialize the Client ```go theme={null} import ( "github.com/zavudev/sdk-go" "github.com/zavudev/sdk-go/option" ) // Basic initialization client := zavudev.NewClient( option.WithAPIKey(os.Getenv("ZAVUDEV_API_KEY")), ) // With custom base URL client := zavudev.NewClient( option.WithAPIKey("zv_live_xxx"), option.WithBaseURL("https://api.zavu.dev"), ) ``` ### Environment Variables The SDK reads `ZAVUDEV_API_KEY` automatically if no key is provided: ```go theme={null} // Will use ZAVUDEV_API_KEY environment variable client := zavudev.NewClient() ``` ## Accessing Response Headers Use `option.WithResponseInto()` to access the full HTTP response including headers (useful for rate limiting): ```go theme={null} import "net/http" var response *http.Response result, err := client.Messages.Send( context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("+14155551234"), Text: zavudev.String("Hello!"), }, option.WithResponseInto(&response), ) if err != nil { panic(err) } remaining := response.Header.Get("X-RateLimit-Remaining") fmt.Println("Remaining requests:", remaining) fmt.Println("Message ID:", result.Message.ID) ``` ## Error Handling ```go theme={null} import ( "errors" "fmt" "github.com/zavudev/sdk-go" ) result, err := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("invalid-number"), Text: zavudev.String("Hello"), }) if err != nil { var apiErr *zavudev.APIError if errors.As(err, &apiErr) { fmt.Println("Status:", apiErr.StatusCode) fmt.Println("Message:", apiErr.Message) } else { fmt.Println("Error:", err) } return } ``` ### Error Types | Error Code | Description | | - | - | | `400` | Invalid request parameters | | `401` | Invalid or missing API key | | `403` | Insufficient permissions | | `404` | Resource not found | | `429` | Rate limit exceeded | ## Retries The SDK supports automatic retries with configurable backoff: ```go theme={null} client := zavudev.NewClient( option.WithAPIKey("zv_live_xxx"), option.WithMaxRetries(3), ) ``` ## Type Safety The SDK provides strongly-typed request and response types: ```go theme={null} params := zavudev.MessageSendParams{ To: zavudev.String("+14155551234"), Text: zavudev.String("Hello!"), Channel: zavudev.String("sms"), MessageType: zavudev.String("text"), } result, err := client.Messages.Send(context.TODO(), params) ``` # Phone Numbers Source: https://docs.zavu.dev/sdks/go/phone-numbers Search, purchase and assign phone numbers via the Zavu Go SDK. Manage SMS, WhatsApp and Voice numbers across countries with one API. Phone numbers are required for sending SMS and WhatsApp messages. You can search for available numbers, purchase them, and manage them through the API. ## Search Available Numbers Find phone numbers available for purchase: ```go theme={null} result, err := client.PhoneNumbers.SearchAvailable(context.TODO(), zavudev.PhoneNumberSearchAvailableParams{ CountryCode: "US", Type: zavudev.PhoneNumberTypeLocal, Limit: zavudev.Int(10), }) if err != nil { panic(err) } for _, number := range result.Items { fmt.Println(number.PhoneNumber, number.Locality, number.Region) fmt.Println("Monthly:", number.Pricing.MonthlyPrice) fmt.Println("Free eligible:", number.Pricing.IsFreeEligible) } ``` ### Search Parameters ```go theme={null} result, err := client.PhoneNumbers.SearchAvailable(context.TODO(), zavudev.PhoneNumberSearchAvailableParams{ CountryCode: "US", // Required: Two-letter country code Type: zavudev.PhoneNumberTypeLocal, // Optional: local, national, tollFree. The API // also takes mobile; this SDK does not type it yet, so use REST for it. Contains: zavudev.String("555"), // Optional: Pattern to search for Limit: zavudev.Int(20), // Optional: Max results (default: 10, max: 50) }) ``` ## Purchase a Phone Number ```go theme={null} result, err := client.PhoneNumbers.Purchase(context.TODO(), zavudev.PhoneNumberPurchaseParams{ PhoneNumber: "+14155551234", Name: zavudev.String("Customer Support"), }) if err != nil { panic(err) } fmt.Println(result.PhoneNumber.ID) // pn_abc123 fmt.Println(result.PhoneNumber.PhoneNumber) // +14155551234 fmt.Println(result.PhoneNumber.Status) // active ``` > [!NOTE] > > Buying numbers requires a paid plan (`402 paid_plan_required` on Free). A paid plan includes one number at no charge, once per account: a US or Canadian number (a +1 number) costing \$20 a month or less; `IsFreeEligible` in search results marks the number that qualifies. > [!NOTE] > > Some numbers require regulatory information before they can be used; the purchase checks the exact number before charging anything. The SDK does not send `type` or `regulatoryRequirements` yet: for those numbers use the REST flow in [Regulatory Requirements](/guides/phone-numbers/regulatory-requirements), then poll the number's regulatory status until it is `approved`. A sender can be assigned before or after approval. ## List Phone Numbers ```go theme={null} result, err := client.PhoneNumbers.List(context.TODO(), zavudev.PhoneNumberListParams{}) if err != nil { panic(err) } for _, number := range result.Items { fmt.Println(number.ID, number.PhoneNumber, number.Name) fmt.Println("Status:", number.Status) fmt.Println("Assigned to:", number.SenderID) } // With filters filtered, err := client.PhoneNumbers.List(context.TODO(), zavudev.PhoneNumberListParams{ Status: zavudev.PhoneNumberStatusActive, Limit: zavudev.Int(50), Cursor: zavudev.String("cursor_xxx"), }) ``` ## Get Phone Number ```go theme={null} result, err := client.PhoneNumbers.Get(context.TODO(), "pn_abc123") if err != nil { panic(err) } fmt.Println(result.PhoneNumber.PhoneNumber) fmt.Println(result.PhoneNumber.Name) fmt.Println(result.PhoneNumber.Capabilities) fmt.Println(result.PhoneNumber.Pricing.MonthlyPrice) fmt.Println(result.PhoneNumber.NextRenewalDate) ``` ## Update Phone Number Update the name or sender assignment: ```go theme={null} // Update name result, err := client.PhoneNumbers.Update(context.TODO(), "pn_abc123", zavudev.PhoneNumberUpdateParams{ Name: zavudev.String("Marketing Line"), }) // Assign to a sender result, err = client.PhoneNumbers.Update(context.TODO(), "pn_abc123", zavudev.PhoneNumberUpdateParams{ SenderID: zavudev.String("snd_xyz789"), }) // Unassign from sender (param is github.com/zavudev/sdk-go/packages/param) result, err = client.PhoneNumbers.Update(context.TODO(), "pn_abc123", zavudev.PhoneNumberUpdateParams{ SenderID: param.Null[string](), }) ``` ## Release Phone Number Release a phone number you no longer need: ```go theme={null} err := client.PhoneNumbers.Release(context.TODO(), "pn_abc123") if err != nil { panic(err) } ``` > [!WARNING] > > You cannot release a phone number assigned to a sender. Unassign it first. ## Response Types ### Phone Number Object ```go theme={null} type OwnedPhoneNumber struct { ID string `json:"id"` PhoneNumber string `json:"phoneNumber"` Name *string `json:"name"` Capabilities []string `json:"capabilities"` // ["sms", "voice", "mms"] Status string `json:"status"` // active, suspended, pending SenderID *string `json:"senderId"` Pricing OwnedPhoneNumberPricing `json:"pricing"` NextRenewalDate *string `json:"nextRenewalDate"` CreatedAt string `json:"createdAt"` UpdatedAt *string `json:"updatedAt"` } type OwnedPhoneNumberPricing struct { MonthlyPrice float64 `json:"monthlyPrice"` UpfrontCost float64 `json:"upfrontCost"` MonthlyCost float64 `json:"monthlyCost"` IsFreeNumber bool `json:"isFreeNumber"` } ``` ### Available Phone Number Object ```go theme={null} type AvailablePhoneNumber struct { PhoneNumber string `json:"phoneNumber"` FriendlyName *string `json:"friendlyName"` Locality *string `json:"locality"` Region *string `json:"region"` Capabilities PhoneNumberCapabilities `json:"capabilities"` Pricing AvailablePhoneNumberPricing `json:"pricing"` } type PhoneNumberCapabilities struct { SMS bool `json:"sms"` Voice bool `json:"voice"` MMS bool `json:"mms"` } type AvailablePhoneNumberPricing struct { MonthlyPrice float64 `json:"monthlyPrice"` UpfrontPrice float64 `json:"upfrontPrice"` IsFreeEligible bool `json:"isFreeEligible"` } ``` ## Error Handling ```go theme={null} _, err := client.PhoneNumbers.Purchase(context.TODO(), zavudev.PhoneNumberPurchaseParams{ PhoneNumber: "+14155551234", }) if err != nil { var apiErr *zavudev.Error if errors.As(err, &apiErr) { switch apiErr.StatusCode { case 402: fmt.Println("Add funds to your account") case 400: fmt.Println("Invalid request, number unavailable, or regulatory information needed:", apiErr.Error()) default: fmt.Println("Error:", apiErr.Error()) } } return } ``` # Manage Senders with the Go SDK Source: https://docs.zavu.dev/sdks/go/senders Create, update and list sender profiles via the Zavu Go SDK. Combine phone numbers, WhatsApp accounts and webhooks into routing identities. Senders are phone numbers or identities used to send messages. Each project can have multiple senders with one designated as default. ## List Senders ```go theme={null} result, err := client.Senders.List(context.TODO(), zavudev.SenderListParams{}) if err != nil { panic(err) } for _, sender := range result.Items { fmt.Println(sender.ID, sender.Name, sender.PhoneNumber) if sender.IsDefault { fmt.Println("(Default sender)") } } // With pagination result, err := client.Senders.List(context.TODO(), zavudev.SenderListParams{ Limit: zavudev.Int(50), Cursor: zavudev.String("cursor_xxx"), }) ``` ## Get Sender ```go theme={null} result, err := client.Senders.Get(context.TODO(), "snd_abc123") if err != nil { panic(err) } fmt.Println(result.ID) fmt.Println(result.Name) fmt.Println(result.PhoneNumber) fmt.Println(result.IsDefault) ``` ## Create Sender ```go theme={null} result, err := client.Senders.New(context.TODO(), zavudev.SenderCreateParams{ Name: zavudev.String("Marketing"), PhoneNumber: zavudev.String("+15551234567"), SetAsDefault: zavudev.Bool(true), }) if err != nil { panic(err) } fmt.Println(result.ID) // snd_xxx ``` ### With Webhook ```go theme={null} result, err := client.Senders.New(context.TODO(), zavudev.SenderCreateParams{ Name: zavudev.String("Marketing"), PhoneNumber: zavudev.String("+15551234567"), WebhookURL: zavudev.String("https://your-app.com/webhooks"), WebhookEvents: []string{ "message.inbound", "message.delivered", "message.failed", }, }) if err != nil { panic(err) } // Webhook secret is only returned on create if result.Webhook != nil && result.Webhook.Secret != "" { fmt.Println("Save this secret:", result.Webhook.Secret) } ``` ## Update Sender ```go theme={null} result, err := client.Senders.Update(context.TODO(), "snd_abc123", zavudev.SenderUpdateParams{ Name: zavudev.String("Support"), SetAsDefault: zavudev.Bool(true), }) ``` ### Update Webhook Configuration ```go theme={null} result, err := client.Senders.Update(context.TODO(), "snd_abc123", zavudev.SenderUpdateParams{ WebhookURL: zavudev.String("https://new-url.com/webhooks"), WebhookEvents: []string{ "message.inbound", }, WebhookActive: zavudev.Bool(true), }) ``` ### Disable Webhook ```go theme={null} result, err := client.Senders.Update(context.TODO(), "snd_abc123", zavudev.SenderUpdateParams{ WebhookActive: zavudev.Bool(false), }) ``` ### Remove Webhook ```go theme={null} result, err := client.Senders.Update(context.TODO(), "snd_abc123", zavudev.SenderUpdateParams{ WebhookURL: nil, }) ``` ## Delete Sender ```go theme={null} err := client.Senders.Delete(context.TODO(), "snd_abc123") if err != nil { panic(err) } ``` > [!NOTE] > > You cannot delete the default sender. Set another sender as default first. ## Regenerate Webhook Secret If your webhook secret is compromised, generate a new one: ```go theme={null} result, err := client.Senders.RegenerateWebhookSecret(context.TODO(), "snd_abc123") if err != nil { panic(err) } fmt.Println("New secret:", result.Secret) ``` > [!WARNING] > > After regenerating the secret, update your webhook handler to use the new secret. The old secret will no longer work. ## WhatsApp Business Profile Manage the WhatsApp Business Profile for senders with a connected WhatsApp Business Account. ### Get Profile ```go theme={null} response, err := client.Senders.GetProfile(context.TODO(), "snd_abc123") if err != nil { panic(err) } fmt.Println(response.Profile.About) fmt.Println(response.Profile.Description) fmt.Println(response.Profile.Address) fmt.Println(response.Profile.Email) fmt.Println(response.Profile.Websites) fmt.Println(response.Profile.Vertical) fmt.Println(response.Profile.ProfilePictureURL) ``` ### Update Profile ```go theme={null} response, err := client.Senders.UpdateProfile(context.TODO(), "snd_abc123", zavudev.WhatsAppBusinessProfileUpdateParams{ About: zavudev.String("Your trusted online store"), Description: zavudev.String("Best products at competitive prices"), Address: zavudev.String("123 Main St, San Francisco, CA"), Email: zavudev.String("support@example.com"), Websites: []string{"https://example.com"}, Vertical: zavudev.String("RETAIL"), }) ``` ### Upload Profile Picture ```go theme={null} response, err := client.Senders.UploadProfilePicture(context.TODO(), "snd_abc123", zavudev.WhatsAppBusinessProfilePictureParams{ ImageURL: zavudev.String("https://example.com/logo.png"), MimeType: zavudev.String("image/png"), }) ``` > [!NOTE] > > Profile methods are only available for senders with a WhatsApp Business Account connected. ## Using Senders in Messages Specify a sender when sending a message: ```go theme={null} result, err := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("+14155551234"), Text: zavudev.String("Hello!"), ZavuSender: zavudev.String("snd_abc123"), }) ``` If no sender is specified, the default sender is used. # Sub-Accounts Source: https://docs.zavu.dev/sdks/go/sub-accounts Isolated sub-accounts with their own API keys and spend limits. Not yet generated in the Go SDK: use the REST API or the CLI. > [!WARNING] > > **Not generated in the Go SDK yet.** `sdk-go` does not expose a `sub-accounts` > resource today, so there is nothing to call. The endpoints exist and are stable: > use the REST API below, the [CLI](/guides/cli/overview), or the > [TypeScript SDK](/sdks/typescript/sub-accounts), which does cover it. See the > [coverage table](/sdks/overview#coverage). Sub-accounts are isolated projects under your team, each with its own API keys and spending cap. Every charge still bills to the parent team's balance. > [!NOTE] > > These endpoints require a **parent project** API key. A sub-account key calling > them receives `403`. ## Create a sub-account ```bash theme={null} curl -X POST https://api.zavu.dev/v1/sub-accounts \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Client ABC", "externalId": "client_123", "creditLimit": 100000 }' ``` `creditLimit` is in cents. The response includes `apiKey`, and that is the only time it is returned. ```bash CLI theme={null} npx zavudev sub-accounts create --data '{"name":"Client ABC"}' ``` ## Retrieve, list and update ```bash theme={null} curl https://api.zavu.dev/v1/sub-accounts/jx7abc123 \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" curl "https://api.zavu.dev/v1/sub-accounts?limit=50" \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" curl -X PATCH https://api.zavu.dev/v1/sub-accounts/jx7abc123 \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "creditLimit": 250000 }' ``` ## Spending ```bash theme={null} curl https://api.zavu.dev/v1/sub-accounts/jx7abc123/balance \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` Returns the parent team's `balance` alongside this sub-account's `totalSpent` and `creditLimit`, all in cents. ## API keys ```bash theme={null} # List curl https://api.zavu.dev/v1/sub-accounts/jx7abc123/api-keys \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" # Create (the full key is returned once) curl -X POST https://api.zavu.dev/v1/sub-accounts/jx7abc123/api-keys \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Production Key", "environment": "live" }' # Revoke curl -X DELETE https://api.zavu.dev/v1/sub-accounts/jx7abc123/api-keys/key_id \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` ## Deactivate ```bash theme={null} curl -X DELETE https://api.zavu.dev/v1/sub-accounts/jx7abc123 \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` Remaining balance returns to the parent team and every API key is revoked. > [card] [Sub-accounts guide](/guides/sub-accounts/overview) > > When to use them, and how billing works > [card] [API reference](/api-reference/create-sub-account) > > Every field on the endpoints # Templates Source: https://docs.zavu.dev/sdks/go/templates Create, list and submit WhatsApp message templates via the Zavu Go SDK. Manage variables, categories and approval status from your code. Templates are reusable message formats for sending structured messages across WhatsApp, SMS, Telegram, and other channels. WhatsApp requires template approval for business-initiated conversations. ## Create Template ```go theme={null} result, err := client.Templates.New(context.TODO(), zavudev.TemplateCreateParams{ Name: zavudev.String("order_confirmation"), Language: zavudev.String("en"), Body: zavudev.String("Hi {{1}}, your order #{{2}} is confirmed!"), WhatsappCategory: zavudev.String("UTILITY"), Variables: []string{"customer_name", "order_id"}, }) if err != nil { panic(err) } fmt.Println(result.ID) // tpl_xxx fmt.Println(result.Status) // draft, pending, approved, rejected ``` ## List Templates ```go theme={null} result, err := client.Templates.List(context.TODO(), zavudev.TemplateListParams{}) if err != nil { panic(err) } for _, template := range result.Items { fmt.Println(template.ID, template.Name, template.Status) } // With pagination result, err := client.Templates.List(context.TODO(), zavudev.TemplateListParams{ Limit: zavudev.Int(50), Cursor: zavudev.String("cursor_xxx"), }) ``` ## Get Template ```go theme={null} result, err := client.Templates.Get(context.TODO(), "tpl_abc123") if err != nil { panic(err) } fmt.Println(result.Name) fmt.Println(result.Body) fmt.Println(result.Status) ``` ## Delete Template ```go theme={null} err := client.Templates.Delete(context.TODO(), "tpl_abc123") if err != nil { panic(err) } ``` ## Submit Template for Approval After creating a template, you need to submit it to Meta for approval before you can use it: ```go theme={null} result, err := client.Templates.Submit(context.TODO(), "tpl_abc123", zavudev.TemplateSubmitParams{ SenderID: zavudev.String("sender_xyz"), Category: zavudev.String("UTILITY"), // Optional if already set on template }) if err != nil { panic(err) } fmt.Println(result.Status) // "pending" ``` > [!NOTE] > > The `SenderID` must reference a sender that has a WhatsApp Business Account configured. Templates are submitted to the WABA associated with that sender. ## Template Categories | Category | Use Case | | - | - | | `UTILITY` | Order updates, account alerts, appointment reminders | | `MARKETING` | Promotions, offers, newsletters | | `AUTHENTICATION` | OTP codes, verification messages | ## Using Templates in Messages ```go theme={null} result, err := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("+14155551234"), MessageType: zavudev.String("template"), Content: &zavudev.MessageContent{ TemplateID: zavudev.String("tpl_abc123"), TemplateVariables: map[string]string{ "1": "John", "2": "ORD-12345", }, }, }) ``` # Zavu SDKs Overview Source: https://docs.zavu.dev/sdks/overview Official Zavu SDKs for TypeScript, Python, Ruby, Go and PHP. Send SMS, WhatsApp, Email and Voice messages with one unified, well-typed API. > [!WARNING] > > **Server-side only** — Zavu SDKs are designed for server-side environments (Node.js, Python, Ruby, Go, PHP). Never use them in browser/frontend code as this exposes your API key. See [Frontend Integration](/authentication#frontend-integration) for the proxy pattern. Use our official SDKs to integrate Zavu into your application with minimal code. ## Available SDKs > [card] [TypeScript](/sdks/typescript/overview) > > TypeScript/JavaScript SDK for Node.js > [card] [Python](/sdks/python/overview) > > Python SDK with sync and async support > [card] [Ruby](/sdks/ruby/overview) > > Ruby SDK for Rails, Sinatra, and more > [card] [Go](/sdks/go/overview) > > Go SDK with strong typing > [card] [PHP](/sdks/php/overview) > > PHP SDK for Laravel, Symfony, and more > [card] [CLI](/guides/functions/cli) > > Command-line interface for Zavu ## Quick Comparison | Feature | TypeScript | Python | Ruby | Go | PHP | | - | - | - | - | - | - | | Package | `@zavudev/sdk` | `zavudev` | `zavudev` | `github.com/zavudev/sdk-go` | `zavudev/sdk` | | Min Version | Node 18+ | Python 3.9+ | Ruby 3.0+ | Go 1.21+ | PHP 8.1+ | | Async | Native Promises | asyncio support | Threads | Goroutines | — | | Types | Full TypeScript | Type hints | — | Strong typing | — | ## Coverage The SDKs are generated from the API spec and do not all move at the same speed. TypeScript tracks the spec most closely. Before you build against a resource, check that your language has it. | Resource | TypeScript | Python | Ruby | Go | PHP | | - | - | - | - | - | - | | Messages, templates, senders, agents | Yes | Yes | Yes | Yes | Yes | | Contacts, broadcasts, phone numbers | Yes | Yes | Yes | Yes | Yes | | Introspect, addresses, regulatory documents | Yes | Yes | Yes | Yes | Yes | | Invitations, URL verification, sub-accounts | Yes | No | Yes | No | No | | Balance, account, functions, 10DLC | Yes | No | Yes | No | No | | Voice calls | No | No | No | No | No | > [!NOTE] > > Nothing is blocked by a gap. Every endpoint is reachable over REST, and the > [CLI](/guides/cli/overview) mirrors the API resource by resource. Where a page > below shows a `cURL` or `CLI` tab instead of your language, that is why. ## Installation ```bash TypeScript theme={null} npm add @zavudev/sdk # or bun add @zavudev/sdk ``` ```bash Python theme={null} pip install zavudev # or uv add zavudev ``` ```bash Ruby theme={null} gem install zavudev # or add to Gemfile gem "zavudev" ``` ```bash Go theme={null} go get github.com/zavudev/sdk-go ``` ```bash PHP theme={null} composer require zavudev/sdk ``` ```bash CLI theme={null} npx zavudev@latest --version # or install it globally: npm install -g zavudev # or grab a standalone binary from # https://github.com/zavudev/zavu-cli/releases ``` ## Basic Usage ```typescript TypeScript theme={null} import Zavudev from '@zavudev/sdk'; const zavu = new Zavudev({ apiKey: process.env['ZAVUDEV_API_KEY'], // This is the default and can be omitted }); const result = await zavu.messages.send({ to: "+14155551234", text: "Hello from Zavu!", }); console.log("Message ID:", result.message.id); ``` ```python Python theme={null} import os from zavudev import Zavudev zavu = Zavudev( api_key=os.environ.get("ZAVUDEV_API_KEY"), # This is the default and can be omitted ) result = zavu.messages.send( to="+14155551234", text="Hello from Zavu!" ) print(f"Message ID: {result.message.id}") ``` ```ruby Ruby theme={null} require "zavudev" client = Zavudev::Client.new( api_key: ENV["ZAVUDEV_API_KEY"] ) result = client.messages.send_( to: "+14155551234", text: "Hello from Zavu!" ) puts "Message ID: #{result.message.id}" ``` ```go Go theme={null} package main import ( "context" "fmt" "github.com/zavudev/sdk-go" "github.com/zavudev/sdk-go/option" ) func main() { client := zavudev.NewClient( option.WithAPIKey(os.Getenv("ZAVUDEV_API_KEY")), ) result, err := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("+14155551234"), Text: zavudev.String("Hello from Zavu!"), }) if err != nil { panic(err) } fmt.Println("Message ID:", result.Message.ID) } ``` ```php PHP theme={null} messages->send([ 'to' => '+14155551234', 'text' => 'Hello from Zavu!', ]); echo "Message ID: " . $result->message->id . "\n"; ``` ```bash CLI theme={null} zavudev messages send --to "+14155551234" --text "Hello from Zavu!" ``` ## Common Operations ### Send a Message ```typescript TypeScript theme={null} const result = await zavu.messages.send({ to: "+14155551234", text: "Your code is 123456", channel: "sms", }); ``` ```python Python theme={null} result = zavu.messages.send( to="+14155551234", text="Your code is 123456", channel="sms" ) ``` ```ruby Ruby theme={null} result = client.messages.send_( to: "+14155551234", text: "Your code is 123456", channel: "sms" ) ``` ```go Go theme={null} result, err := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("+14155551234"), Text: zavudev.String("Your code is 123456"), Channel: zavudev.String("sms"), }) ``` ```php PHP theme={null} $result = $client->messages->send([ 'to' => '+14155551234', 'text' => 'Your code is 123456', 'channel' => 'sms', ]); ``` ```bash CLI theme={null} zavudev messages send --to "+14155551234" --text "Your code is 123456" --channel sms ``` ### Send with Template ```typescript TypeScript theme={null} const result = await zavu.messages.send({ to: "+14155551234", messageType: "template", content: { templateId: "tpl_abc123", templateVariables: { "1": "John", "2": "12345", }, }, }); ``` ```python Python theme={null} result = zavu.messages.send( to="+14155551234", message_type="template", content={ "template_id": "tpl_abc123", "template_variables": { "1": "John", "2": "12345", }, } ) ``` ```ruby Ruby theme={null} result = client.messages.send_( to: "+14155551234", message_type: "template", content: { template_id: "tpl_abc123", template_variables: { "1" => "John", "2" => "12345", }, } ) ``` ```go Go theme={null} result, err := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("+14155551234"), MessageType: zavudev.String("template"), Content: &zavudev.MessageContentParams{ TemplateID: zavudev.String("tpl_abc123"), TemplateVariables: map[string]string{ "1": "John", "2": "12345", }, }, }) ``` ```php PHP theme={null} $result = $client->messages->send([ 'to' => '+14155551234', 'messageType' => 'template', 'content' => [ 'templateId' => 'tpl_abc123', 'templateVariables' => [ '1' => 'John', '2' => '12345', ], ], ]); ``` ```bash cURL theme={null} # `zavudev messages send` covers text on any channel. Template sends carry a # nested `content` object, so use the API directly for those. curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "+14155551234", "messageType": "template", "content": { "templateId": "tpl_abc123", "templateVariables": { "1": "John", "2": "12345" } } }' ``` ### Get Message Status ```typescript TypeScript theme={null} const result = await zavu.messages.retrieve("msg_abc123"); console.log("Status:", result.message.status); ``` ```python Python theme={null} result = zavu.messages.retrieve(message_id="msg_abc123") print(f"Status: {result.message.status}") ``` ```ruby Ruby theme={null} result = client.messages.retrieve(message_id: "msg_abc123") puts "Status: #{result.message.status}" ``` ```go Go theme={null} result, err := client.Messages.Get(context.TODO(), "msg_abc123") fmt.Println("Status:", result.Message.Status) ``` ```php PHP theme={null} $result = $client->messages->retrieve(['messageId' => 'msg_abc123']); echo "Status: " . $result->message->status . "\n"; ``` ```bash CLI theme={null} zavudev messages get msg_abc123 ``` ### List Messages ```typescript TypeScript theme={null} const result = await zavu.messages.list({ status: "delivered", limit: 50, }); for (const message of result.items) { console.log(message.id); } ``` ```python Python theme={null} result = zavu.messages.list( status="delivered", limit=50 ) for message in result.items: print(message.id) ``` ```ruby Ruby theme={null} result = client.messages.list( status: "delivered", limit: 50 ) result.items.each { |message| puts message.id } ``` ```go Go theme={null} result, err := client.Messages.List(context.TODO(), zavudev.MessageListParams{ Status: zavudev.String("delivered"), Limit: zavudev.Int(50), }) for _, message := range result.Items { fmt.Println(message.ID) } ``` ```php PHP theme={null} $result = $client->messages->list([ 'status' => 'delivered', 'limit' => 50, ]); foreach ($result->items as $message) { echo $message->id . "\n"; } ``` ```bash CLI theme={null} zavudev messages list --status delivered --limit 50 ``` ## Error Handling ```typescript TypeScript theme={null} import Zavudev, { APIError } from '@zavudev/sdk'; const zavu = new Zavudev({ apiKey: process.env["ZAVUDEV_API_KEY"], }); try { await zavu.messages.send({ to: "invalid", text: "Hello" }); } catch (error) { if (error instanceof APIError) { console.error("API Error:", error.status, error.message); } } ``` ```python Python theme={null} import os from zavudev import Zavudev, APIError zavu = Zavudev( api_key=os.environ.get("ZAVUDEV_API_KEY"), ) try: zavu.messages.send(to="invalid", text="Hello") except APIError as e: print(f"API Error: {e.status_code} - {e.message}") ``` ```ruby Ruby theme={null} require "zavudev" client = Zavudev::Client.new( api_key: ENV["ZAVUDEV_API_KEY"] ) begin client.messages.send_(to: "invalid", text: "Hello") rescue Zavudev::Errors::APIError => e puts "API Error: #{e.status} - #{e.message}" end ``` ```go Go theme={null} result, err := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: "invalid", Text: zavudev.String("Hello"), }) if err != nil { var apiErr *zavudev.APIError if errors.As(err, &apiErr) { fmt.Printf("API Error: %d - %s\n", apiErr.StatusCode, apiErr.Message) } } ``` ```php PHP theme={null} use Zavudev\Client; use Zavudev\APIError; $client = new Client(apiKey: getenv('ZAVUDEV_API_KEY')); try { $client->messages->send(['to' => 'invalid', 'text' => 'Hello']); } catch (APIError $e) { echo "API Error: " . $e->status . " - " . $e->getMessage() . "\n"; } ``` ## CLI The Zavu CLI lets you interact with the API and deploy Zavu Functions from your terminal: ```bash theme={null} # No install needed: npx runs the current version npx zavudev@latest --version # Authenticate (opens browser, picks a project) npx zavudev login # Send a message npx zavudev messages send --to "+14155551234" --text "Hello from CLI!" # List messages npx zavudev messages list --limit 10 # Get help npx zavudev --help ``` Full reference: [CLI reference](/guides/functions/cli). # Broadcasts Source: https://docs.zavu.dev/sdks/php/broadcasts Create, schedule and track bulk message broadcasts via the Zavu PHP SDK. Add contacts, send to thousands and monitor delivery progress. Broadcasts allow you to send messages to multiple recipients in a single campaign. ## Create Broadcast ```php theme={null} $result = $client->broadcasts->create([ 'name' => 'Black Friday Sale', 'channel' => 'sms', 'text' => 'Hi {{name}}, check out our Black Friday deals! Use code FRIDAY20 for 20% off.', ]); echo $result->broadcast->id . "\n"; // brd_xxx echo $result->broadcast->status . "\n"; // draft ``` ### WhatsApp Template Broadcast ```php theme={null} $result = $client->broadcasts->create([ 'name' => 'Order Confirmation Campaign', 'channel' => 'whatsapp', 'messageType' => 'template', 'content' => [ 'templateId' => 'tpl_abc123', ], ]); ``` ### Email Broadcast ```php theme={null} $result = $client->broadcasts->create([ 'name' => 'Newsletter', 'channel' => 'email', 'emailSubject' => 'Your Weekly Update', 'text' => 'Hi {{name}}, here\'s what\'s new this week...', ]); ``` ## Get Broadcast ```php theme={null} $result = $client->broadcasts->retrieve([ 'broadcastId' => 'brd_abc123', ]); echo $result->broadcast->name . "\n"; echo $result->broadcast->status . "\n"; echo $result->broadcast->totalContacts . "\n"; ``` ## List Broadcasts ```php theme={null} $result = $client->broadcasts->list([]); foreach ($result->items as $broadcast) { echo $broadcast->id . " " . $broadcast->name . " " . $broadcast->status . "\n"; } // Filter by status $result = $client->broadcasts->list([ 'status' => 'completed', 'limit' => 50, ]); ``` ## Update Broadcast Update a broadcast in draft status: ```php theme={null} $result = $client->broadcasts->update([ 'broadcastId' => 'brd_abc123', 'name' => 'Updated Campaign Name', 'text' => 'New message content with {{name}}', ]); ``` ## Add Contacts Add contacts to a broadcast (max 1000 per request): ```php theme={null} $result = $client->broadcasts->contacts->add([ 'broadcastId' => 'brd_abc123', 'contacts' => [ [ 'recipient' => '+14155551234', 'templateVariables' => [ 'name' => 'John', 'order_id' => 'ORD-001', ], ], [ 'recipient' => '+14155555678', 'templateVariables' => [ 'name' => 'Jane', 'order_id' => 'ORD-002', ], ], ], ]); echo $result->added . "\n"; // 2 echo $result->duplicates . "\n"; // 0 echo $result->invalid . "\n"; // 0 ``` ## List Broadcast Contacts ```php theme={null} $result = $client->broadcasts->contacts->list([ 'broadcastId' => 'brd_abc123', 'status' => 'pending', 'limit' => 100, ]); foreach ($result->items as $contact) { echo $contact->recipient . " " . $contact->status . "\n"; } ``` ## Remove Contact ```php theme={null} $client->broadcasts->contacts->remove([ 'broadcastId' => 'brd_abc123', 'contactId' => 'bcon_xyz789', ]); ``` ## Send Broadcast Start sending immediately: ```php theme={null} $result = $client->broadcasts->send([ 'broadcastId' => 'brd_abc123', ]); echo $result->broadcast->status . "\n"; // sending ``` Schedule for later: ```php theme={null} $result = $client->broadcasts->send([ 'broadcastId' => 'brd_abc123', 'scheduledAt' => '2024-01-15T10:00:00Z', ]); echo $result->broadcast->status . "\n"; // scheduled ``` ## Reschedule Broadcast Change the scheduled time for a broadcast in `scheduled` status: ```php theme={null} $result = $client->broadcasts->reschedule([ 'broadcastId' => 'brd_abc123', 'scheduledAt' => '2024-01-16T14:00:00Z', ]); echo $result->broadcast->scheduledAt . "\n"; // 2024-01-16T14:00:00.000Z ``` > [!NOTE] > > You can only reschedule broadcasts that are in `scheduled` status. The new time must be in the future. ## Get Progress Monitor broadcast progress in real-time: ```php theme={null} $result = $client->broadcasts->progress([ 'broadcastId' => 'brd_abc123', ]); echo $result->status . "\n"; // sending echo $result->total . "\n"; // 5000 echo $result->delivered . "\n"; // 2350 echo $result->failed . "\n"; // 50 echo $result->percentComplete . "\n"; // 48.0 echo $result->estimatedCompletionAt . "\n"; ``` ## Cancel Broadcast ```php theme={null} $result = $client->broadcasts->cancel([ 'broadcastId' => 'brd_abc123', ]); echo $result->broadcast->status . "\n"; // cancelled ``` > [!WARNING] > > Cancelling a broadcast will skip pending contacts, but messages already queued may still be delivered. ## Retry Review Resubmit a rejected broadcast for AI review after editing content: > [!NOTE] > > Maximum 3 review attempts are allowed per broadcast. After that, use escalate for manual review. ## Escalate to Manual Review Request manual review by the Zavu team for a rejected broadcast: ## Delete Broadcast Delete a broadcast in draft status: ```php theme={null} $client->broadcasts->delete([ 'broadcastId' => 'brd_abc123', ]); ``` # Contacts Source: https://docs.zavu.dev/sdks/php/contacts Create, list, retrieve and update contacts via the Zavu PHP SDK. Manage profile metadata, channels and merge suggestions from your code. Contacts are automatically created when you send messages. You can retrieve and update contact information. ## Get Contact ```php theme={null} $result = $client->contacts->retrieve([ 'contactId' => 'con_abc123', ]); echo $result->id . "\n"; echo $result->phoneNumber . "\n"; echo $result->countryCode . "\n"; echo implode(', ', $result->availableChannels) . "\n"; echo $result->defaultChannel . "\n"; ``` ## Get Contact by Phone ```php theme={null} $result = $client->contacts->retrieveByPhone([ 'phoneNumber' => '+14155551234', ]); echo $result->id . "\n"; echo implode(', ', $result->availableChannels) . "\n"; // ["sms", "whatsapp"] ``` ## Create Contact > [!NOTE] > > `create`, `delete`, `merge`, `dismissMergeSuggestion` and the channel operations > are not generated in the PHP SDK yet. The endpoints work: call them directly, as > shown below. See the [coverage table](/sdks/overview#coverage). ```bash theme={null} curl -X POST https://api.zavu.dev/v1/contacts \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "displayName": "John Doe", "channels": [ { "channel": "sms", "identifier": "+14155551234", "isPrimary": true } ] }' ``` ## Update Contact ```php theme={null} $result = $client->contacts->update([ 'contactId' => 'con_abc123', 'defaultChannel' => 'whatsapp', 'metadata' => [ 'name' => 'John Doe', 'tier' => 'premium', ], ]); ``` ## List Contacts ```php theme={null} $result = $client->contacts->list([ 'limit' => 50, ]); foreach ($result->items as $contact) { echo $contact->id . " " . $contact->phoneNumber . "\n"; } // Filter by phone number $result = $client->contacts->list([ 'phoneNumber' => '+1415', ]); // Pagination $cursor = null; do { $result = $client->contacts->list(['cursor' => $cursor, 'limit' => 50]); foreach ($result->items as $contact) { echo $contact->id . "\n"; } $cursor = $result->nextCursor; } while ($cursor !== null); ``` ## Channels Adding, updating, promoting and removing a contact's channels are REST calls today: ```bash theme={null} # Add curl -X POST https://api.zavu.dev/v1/contacts/con_abc123/channels \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "channel": "email", "identifier": "john.work@company.com", "label": "work" }' # Update curl -X PATCH https://api.zavu.dev/v1/contacts/con_abc123/channels/ch_xyz789 \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "label": "personal", "verified": true }' # Set as primary for its type curl -X POST https://api.zavu.dev/v1/contacts/con_abc123/channels/ch_xyz789/primary \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" # Remove curl -X DELETE https://api.zavu.dev/v1/contacts/con_abc123/channels/ch_xyz789 \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` > [!WARNING] > > You cannot remove the last channel from a contact. ## Merge Contacts ```bash theme={null} # Merge the source contact into this one. Channels move to the target and the # source stops appearing in listings. This cannot be undone. curl -X POST https://api.zavu.dev/v1/contacts/con_abc123/merge \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "sourceContactId": "con_xyz789" }' ``` > [!NOTE] > > Zavu does not detect duplicates for you — choose which contacts to merge from > your own data. ## Phone Introspection Validate a phone number and check available channels: ```php theme={null} $result = $client->introspect->phone([ 'phoneNumber' => '+14155551234', ]); echo $result->validNumber . "\n"; // true echo $result->countryCode . "\n"; // "US" echo $result->nationalFormat . "\n"; // "(415) 555-1234" echo $result->lineType . "\n"; // "mobile" echo implode(', ', $result->availableChannels) . "\n"; // ["sms", "whatsapp"] echo $result->carrier?->name . "\n"; // "Verizon Wireless" ``` # Invitations Source: https://docs.zavu.dev/sdks/php/invitations Partner invitations for client WhatsApp Business onboarding. Not yet generated in the PHP SDK: use the REST API or the CLI. > [!WARNING] > > **Not generated in the PHP SDK yet.** `zavudev/sdk` does not expose an `invitations` > resource today, so there is nothing to call. The endpoints exist and are stable: > use the REST API below, the [CLI](/guides/cli/overview), or the > [TypeScript SDK](/sdks/typescript/invitations), which does cover it. See the > [coverage table](/sdks/overview#coverage). Partner invitations generate a link a client opens to connect their own WhatsApp Business Account, or a Facebook Page, into your project. One invitation connects one channel. ## Create an invitation ```bash theme={null} curl -X POST https://api.zavu.dev/v1/invitations \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "clientName": "Acme Corp", "clientEmail": "contact@acme.com", "connectionType": "whatsapp_waba", "expiresInDays": 14 }' ``` The response carries `url`, the link you share with the client, and `token`. ```bash CLI theme={null} npx zavudev invitations create --data '{"clientName":"Acme Corp"}' ``` ## Retrieve an invitation ```bash theme={null} curl https://api.zavu.dev/v1/invitations/inv_abc123 \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` Poll `status` (`pending`, `in_progress`, `completed`, `expired`, `cancelled`, `failed`), or subscribe to the `invitation.status_changed` webhook instead of polling. A `failed` invitation is still usable: the same link can be retried. ## List invitations ```bash theme={null} curl "https://api.zavu.dev/v1/invitations?status=pending&limit=50" \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` ## Cancel an invitation ```bash theme={null} curl -X POST https://api.zavu.dev/v1/invitations/inv_abc123/cancel \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` ## After completion When the client finishes, the invitation moves to `completed` and carries `senderId` plus `connectedAccount` (the WhatsApp number or Facebook Page that was linked). That sender is now yours to send from. > [card] [Partner invitations guide](/guides/partner-invitations/api) > > The full flow, webhooks, and failure codes > [card] [API reference](/api-reference/create-invitation) > > Every field on the endpoint # Send Messages with the PHP SDK Source: https://docs.zavu.dev/sdks/php/messages Send and manage SMS, WhatsApp, Email, and Voice messages with the Zavu PHP SDK. Production-ready, with PSR-aware helpers for retries and delivery tracking. ## Send a Message ```php theme={null} $result = $client->messages->send([ 'to' => '+14155551234', 'text' => 'Your verification code is 123456', 'channel' => 'sms', // optional: sms, whatsapp, email 'metadata' => [ 'userId' => 'user_123', ], 'idempotencyKey' => 'unique-key', // optional: prevent duplicates ]); echo $result->message->id . "\n"; // msg_xxx echo $result->message->status . "\n"; // queued ``` ## Send with Sender Specify which sender profile to use: ```php theme={null} $result = $client->messages->send([ 'to' => '+14155551234', 'text' => 'Hello!', 'Zavu-Sender' => 'snd_abc123', ]); ``` ## Send with Template Send a WhatsApp template message: ```php theme={null} $result = $client->messages->send([ 'to' => '+14155551234', 'messageType' => 'template', 'content' => [ 'templateId' => 'tpl_abc123', 'templateVariables' => [ '1' => 'John', '2' => '12345', ], ], ]); ``` ## Send WhatsApp Media ### Image ```php theme={null} $result = $client->messages->send([ 'to' => '+14155551234', 'messageType' => 'image', 'text' => 'Check out this product!', 'content' => [ 'mediaUrl' => 'https://example.com/image.jpg', ], ]); ``` ### Document ```php theme={null} $result = $client->messages->send([ 'to' => '+14155551234', 'messageType' => 'document', 'content' => [ 'mediaUrl' => 'https://example.com/invoice.pdf', 'filename' => 'invoice.pdf', ], ]); ``` ### Video ```php theme={null} $result = $client->messages->send([ 'to' => '+14155551234', 'messageType' => 'video', 'text' => 'Watch this!', 'content' => [ 'mediaUrl' => 'https://example.com/video.mp4', ], ]); ``` ## Send Interactive Messages ### Buttons ```php theme={null} $result = $client->messages->send([ 'to' => '+14155551234', 'messageType' => 'buttons', 'text' => 'How would you rate your experience?', 'content' => [ 'buttons' => [ ['id' => 'great', 'title' => 'Great!'], ['id' => 'okay', 'title' => 'It was okay'], ['id' => 'poor', 'title' => 'Not good'], ], ], ]); ``` ### List ```php theme={null} $result = $client->messages->send([ 'to' => '+14155551234', 'messageType' => 'list', 'text' => 'Select an option:', 'content' => [ 'listButton' => 'View Options', 'sections' => [ [ 'title' => 'Products', 'rows' => [ ['id' => 'prod_1', 'title' => 'Product A', 'description' => '$10.00'], ['id' => 'prod_2', 'title' => 'Product B', 'description' => '$20.00'], ], ], ], ], ]); ``` ## Send Email ```php theme={null} $result = $client->messages->send([ 'to' => 'user@example.com', 'channel' => 'email', 'subject' => 'Your order has shipped', 'text' => 'Hi John, your order #12345 has shipped.', 'htmlBody' => '

Order Shipped

Your order #12345 has shipped.

', 'replyTo' => 'support@example.com', ]); ``` ## Send Email with Attachments ```php theme={null} $content = base64_encode(file_get_contents('invoice.pdf')); $result = $client->messages->send([ 'to' => 'user@example.com', 'channel' => 'email', 'subject' => 'Your invoice', 'text' => 'Please find your invoice attached.', 'attachments' => [ [ 'filename' => 'invoice.pdf', 'content' => $content, 'content_type' => 'application/pdf', ], ], ]); ``` ### Attachment from URL ```php theme={null} $result = $client->messages->send([ 'to' => 'user@example.com', 'channel' => 'email', 'subject' => 'Your report', 'text' => 'Report attached.', 'attachments' => [ [ 'filename' => 'report.pdf', 'path' => 'https://your-server.com/reports/report.pdf', ], ], ]); ``` ## Get Message Status ```php theme={null} $result = $client->messages->retrieve([ 'messageId' => 'msg_abc123', ]); echo $result->message->status . "\n"; // queued, sending, sent, delivered, failed ``` ## List Messages ```php theme={null} // List all messages $result = $client->messages->list([]); // With filters $result = $client->messages->list([ 'status' => 'delivered', 'channel' => 'sms', 'limit' => 100, ]); // Pagination $cursor = null; do { $result = $client->messages->list(['cursor' => $cursor, 'limit' => 50]); foreach ($result->items as $message) { echo $message->id . "\n"; } $cursor = $result->nextCursor; } while ($cursor !== null); ``` ## Send Reaction React to a WhatsApp message: ```php theme={null} $result = $client->messages->react([ 'messageId' => 'msg_abc123', 'emoji' => "\u{1F44D}", // thumbs up ]); ``` # PHP SDK Overview Source: https://docs.zavu.dev/sdks/php/overview Official Zavu SDK for PHP. Send SMS, WhatsApp, Email and Voice messages from Laravel or any PHP app with PSR-aware HTTP helpers and retries. > [!WARNING] > > **Server-side only** — This SDK is designed for server-side environments (Laravel, Symfony, PHP scripts). Never use it in browser/frontend code as this exposes your API key. See [Frontend Integration](/authentication#frontend-integration) for the proxy pattern. > [!NOTE] > > **Coverage.** The PHP SDK does not generate invitations, URL verification, sub-accounts, balance, account, functions and 10DLC yet, > and no SDK generates voice calls. Those endpoints are still reachable: > use the [CLI](/guides/cli/overview) or the REST API. See the > [coverage table](/sdks/overview#coverage). The official Zavu SDK for PHP provides a clean interface to the Zavu API with full type support. ## Installation ```bash theme={null} composer require zavudev/sdk ``` ## Requirements * PHP 8.1 or higher * Composer ## Quick Start ```php theme={null} messages->send([ 'to' => '+14155551234', 'text' => 'Hello from Zavu!', ]); echo "Message sent: " . $result->message->id . "\n"; ``` ## Configuration ### Initialize the Client ```php theme={null} use Zavudev\Client; // Basic initialization $client = new Client( apiKey: getenv('ZAVUDEV_API_KEY') ); // With custom base URL $client = new Client( apiKey: 'zv_live_xxx', baseURL: 'https://api.zavu.dev' ); ``` ### Environment Variables We recommend storing your API key in environment variables: ```php theme={null} $client = new Client( apiKey: getenv('ZAVUDEV_API_KEY') ); ``` ## Accessing Response Headers Use `->withRawResponse()` to access the full HTTP response including headers: ```php theme={null} $response = $client->messages->withRawResponse()->send([ 'to' => '+14155551234', 'text' => 'Hello!', ]); $remaining = $response->headers['X-RateLimit-Remaining']; echo "Remaining requests: $remaining\n"; $result = $response->parse(); echo "Message ID: " . $result->message->id . "\n"; ``` ## Error Handling ```php theme={null} use Zavudev\Client; use Zavudev\APIError; use Zavudev\ZavudevError; $client = new Client( apiKey: getenv('ZAVUDEV_API_KEY') ); try { $client->messages->send([ 'to' => 'invalid-number', 'text' => 'Hello', ]); } catch (APIError $e) { echo "Status: " . $e->status . "\n"; echo "Message: " . $e->getMessage() . "\n"; } catch (ZavudevError $e) { echo "Error: " . $e->getMessage() . "\n"; } ``` ### Error Types | Error Code | Description | | - | - | | `400` | Invalid request parameters | | `401` | Invalid or missing API key | | `403` | Insufficient permissions | | `404` | Resource not found | | `429` | Rate limit exceeded | ## Retries The SDK supports automatic retries with configurable backoff: ```php theme={null} $client = new Client( apiKey: 'zv_live_xxx', maxRetries: 3 ); $result = $client->messages->send([ 'to' => '+14155551234', 'text' => 'Hello!', ]); ``` ## Laravel Integration For Laravel applications, set your API key in `.env`: ``` ZAVUDEV_API_KEY=zv_live_xxx ``` Use the SDK in your controllers or services: ```php theme={null} use Zavudev\Client; class MessageController extends Controller { private Client $zavu; public function __construct() { $this->zavu = new Client( apiKey: config('services.zavu.key') ); } public function send(Request $request) { $result = $this->zavu->messages->send([ 'to' => $request->input('to'), 'text' => $request->input('text'), ]); return response()->json(['messageId' => $result->message->id]); } } ``` ## Symfony Integration For Symfony applications, configure the API key as a parameter: ```yaml theme={null} # config/services.yaml parameters: zavudev_api_key: '%env(ZAVUDEV_API_KEY)%' ``` ```php theme={null} use Zavudev\Client; class MessageService { private Client $zavu; public function __construct(string $zavudevApiKey) { $this->zavu = new Client( apiKey: $zavudevApiKey ); } public function send(string $to, string $text): string { $result = $this->zavu->messages->send([ 'to' => $to, 'text' => $text, ]); return $result->message->id; } } ``` # Phone Numbers Source: https://docs.zavu.dev/sdks/php/phone-numbers Search, purchase and assign phone numbers via the Zavu PHP SDK. Manage SMS, WhatsApp and Voice numbers across countries with one API. Phone numbers are required for sending SMS and WhatsApp messages. You can search for available numbers, purchase them, and manage them through the API. ## Search Available Numbers Find phone numbers available for purchase: ```php theme={null} $result = $client->phoneNumbers->searchAvailable(countryCode: 'US', type: 'local', limit: 10); foreach ($result->items as $number) { echo $number->phoneNumber . " " . $number->locality . " " . $number->region . "\n"; echo "Monthly: " . $number->pricing->monthlyPrice . "\n"; echo "Free eligible: " . ($number->pricing->isFreeEligible ? 'yes' : 'no') . "\n"; } ``` ### Search Parameters ```php theme={null} $result = $client->phoneNumbers->searchAvailable( countryCode: 'US', // Required: Two-letter country code type: 'local', // Optional: local, national, tollFree. The API also // takes mobile; this SDK does not type it yet, so use REST for it. contains: '555', // Optional: Pattern to search for limit: 20, // Optional: Max results (default: 10, max: 50) ); ``` ## Purchase a Phone Number ```php theme={null} $phoneNumber = $client->phoneNumbers->purchase(phoneNumber: '+14155551234', name: 'Customer Support'); echo $phoneNumber->phoneNumber->id . "\n"; // pn_abc123 echo $phoneNumber->phoneNumber->phoneNumber . "\n"; // +14155551234 echo $phoneNumber->phoneNumber->status . "\n"; // active ``` > [!NOTE] > > Buying numbers requires a paid plan (`402 paid_plan_required` on Free). A paid plan includes one number at no charge, once per account: a US or Canadian number (a +1 number) costing \$20 a month or less; `isFreeEligible` in search results marks the number that qualifies. > [!NOTE] > > Some numbers require regulatory information before they can be used; the purchase checks the exact number before charging anything. The SDK does not send `type` or `regulatoryRequirements` yet: for those numbers use the REST flow in [Regulatory Requirements](/guides/phone-numbers/regulatory-requirements), then poll the number's regulatory status until it is `approved`. A sender can be assigned before or after approval. ## List Phone Numbers ```php theme={null} $result = $client->phoneNumbers->list(); foreach ($result->items as $number) { echo $number->id . " " . $number->phoneNumber . " " . $number->name . "\n"; echo "Status: " . $number->status . "\n"; echo "Assigned to: " . $number->senderID . "\n"; } // With filters $result = $client->phoneNumbers->list(status: 'active', limit: 50, cursor: 'cursor_xxx'); ``` ## Get Phone Number ```php theme={null} $result = $client->phoneNumbers->retrieve('pn_abc123'); echo $result->phoneNumber->phoneNumber . "\n"; echo $result->phoneNumber->name . "\n"; echo implode(', ', $result->phoneNumber->capabilities) . "\n"; echo $result->phoneNumber->pricing->monthlyPrice . "\n"; echo $result->phoneNumber->nextRenewalDate . "\n"; ``` ## Update Phone Number Update the name or sender assignment: ```php theme={null} // Update name $result = $client->phoneNumbers->update('pn_abc123', name: 'Marketing Line'); // Assign to a sender $result = $client->phoneNumbers->update('pn_abc123', senderID: 'snd_xyz789'); ``` ## Release Phone Number Release a phone number you no longer need: ```php theme={null} $client->phoneNumbers->release('pn_abc123'); ``` > [!WARNING] > > You cannot release a phone number assigned to a sender. Unassign it first. ## Get Regulatory Requirements Check what regulatory information is required for a country and number type. The purchase checks the exact number, which the REST endpoint answers with `?phoneNumber=`; see [Regulatory Requirements](/guides/phone-numbers/regulatory-requirements). ```php theme={null} $result = $client->phoneNumbers->requirements(countryCode: 'DE', type: 'local'); foreach ($result->items as $requirement) { echo $requirement->countryCode . " " . $requirement->phoneNumberType . "\n"; foreach ($requirement->requirementTypes as $reqType) { echo " - " . $reqType->name . ": " . $reqType->description . "\n"; } } ``` ## Error Handling ```php theme={null} use Zavudev\Core\Exceptions\APIStatusException; try { $client->phoneNumbers->purchase(phoneNumber: '+14155551234'); } catch (APIStatusException $e) { if ($e->status === 402) { echo "Add funds to your account\n"; } elseif ($e->status === 400) { echo "Invalid request, number unavailable, or regulatory information needed\n"; } } ``` # Manage Senders with the PHP SDK Source: https://docs.zavu.dev/sdks/php/senders Create, update and list sender profiles via the Zavu PHP SDK. Combine phone numbers, WhatsApp accounts and webhooks into routing identities. Senders are phone numbers or identities used to send messages. Each project can have multiple senders with one designated as default. ## List Senders ```php theme={null} $result = $client->senders->list([]); foreach ($result->items as $sender) { echo $sender->id . " " . $sender->name . " " . $sender->phoneNumber . "\n"; if ($sender->isDefault) { echo "(Default sender)\n"; } } // With pagination $result = $client->senders->list([ 'limit' => 50, 'cursor' => 'cursor_xxx', ]); ``` ## Get Sender ```php theme={null} $result = $client->senders->retrieve([ 'senderId' => 'snd_abc123', ]); echo $result->id . "\n"; echo $result->name . "\n"; echo $result->phoneNumber . "\n"; echo $result->isDefault . "\n"; ``` ## Create Sender ```php theme={null} $result = $client->senders->create([ 'name' => 'Marketing', 'phoneNumber' => '+15551234567', 'setAsDefault' => true, ]); echo $result->id . "\n"; // snd_xxx ``` ### With Webhook ```php theme={null} $result = $client->senders->create([ 'name' => 'Marketing', 'phoneNumber' => '+15551234567', 'webhookUrl' => 'https://your-app.com/webhooks', 'webhookEvents' => ['message.inbound', 'message.delivered', 'message.failed'], ]); // Webhook secret is only returned on create if ($result->webhook?->secret) { echo "Save this secret: " . $result->webhook->secret . "\n"; } ``` ## Update Sender ```php theme={null} $result = $client->senders->update([ 'senderId' => 'snd_abc123', 'name' => 'Support', 'setAsDefault' => true, ]); ``` ### Update Webhook Configuration ```php theme={null} $result = $client->senders->update([ 'senderId' => 'snd_abc123', 'webhookUrl' => 'https://new-url.com/webhooks', 'webhookEvents' => ['message.inbound'], 'webhookActive' => true, ]); ``` ### Disable Webhook ```php theme={null} $result = $client->senders->update([ 'senderId' => 'snd_abc123', 'webhookActive' => false, ]); ``` ### Remove Webhook ```php theme={null} $result = $client->senders->update([ 'senderId' => 'snd_abc123', 'webhookUrl' => null, ]); ``` ## Delete Sender ```php theme={null} $client->senders->delete([ 'senderId' => 'snd_abc123', ]); ``` > [!NOTE] > > You cannot delete the default sender. Set another sender as default first. ## Regenerate Webhook Secret If your webhook secret is compromised, generate a new one: ```php theme={null} $result = $client->senders->regenerateWebhookSecret([ 'senderId' => 'snd_abc123', ]); echo "New secret: " . $result->secret . "\n"; ``` > [!WARNING] > > After regenerating the secret, update your webhook handler to use the new secret. The old secret will no longer work. ## WhatsApp Business Profile Manage the WhatsApp Business Profile for senders with a connected WhatsApp Business Account. ### Get Profile ```php theme={null} $response = $client->senders->getProfile('snd_abc123'); echo $response->profile->about . "\n"; echo $response->profile->description . "\n"; echo $response->profile->address . "\n"; echo $response->profile->email . "\n"; echo implode(', ', $response->profile->websites ?? []) . "\n"; echo $response->profile->vertical . "\n"; echo $response->profile->profilePictureUrl . "\n"; ``` ### Update Profile ```php theme={null} $response = $client->senders->updateProfile('snd_abc123', [ 'about' => 'Your trusted online store', 'description' => 'Best products at competitive prices', 'address' => '123 Main St, San Francisco, CA', 'email' => 'support@example.com', 'websites' => ['https://example.com'], 'vertical' => 'RETAIL', ]); ``` ### Upload Profile Picture ```php theme={null} $response = $client->senders->uploadProfilePicture('snd_abc123', [ 'imageUrl' => 'https://example.com/logo.png', 'mimeType' => 'image/png', ]); ``` > [!NOTE] > > Profile methods are only available for senders with a WhatsApp Business Account connected. ## Using Senders in Messages Specify a sender when sending a message: ```php theme={null} $result = $client->messages->send([ 'to' => '+14155551234', 'text' => 'Hello!', 'Zavu-Sender' => 'snd_abc123', ]); ``` If no sender is specified, the default sender is used. # Sub-Accounts Source: https://docs.zavu.dev/sdks/php/sub-accounts Isolated sub-accounts with their own API keys and spend limits. Not yet generated in the PHP SDK: use the REST API or the CLI. > [!WARNING] > > **Not generated in the PHP SDK yet.** `zavudev/sdk` does not expose a `sub-accounts` > resource today, so there is nothing to call. The endpoints exist and are stable: > use the REST API below, the [CLI](/guides/cli/overview), or the > [TypeScript SDK](/sdks/typescript/sub-accounts), which does cover it. See the > [coverage table](/sdks/overview#coverage). Sub-accounts are isolated projects under your team, each with its own API keys and spending cap. Every charge still bills to the parent team's balance. > [!NOTE] > > These endpoints require a **parent project** API key. A sub-account key calling > them receives `403`. ## Create a sub-account ```bash theme={null} curl -X POST https://api.zavu.dev/v1/sub-accounts \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Client ABC", "externalId": "client_123", "creditLimit": 100000 }' ``` `creditLimit` is in cents. The response includes `apiKey`, and that is the only time it is returned. ```bash CLI theme={null} npx zavudev sub-accounts create --data '{"name":"Client ABC"}' ``` ## Retrieve, list and update ```bash theme={null} curl https://api.zavu.dev/v1/sub-accounts/jx7abc123 \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" curl "https://api.zavu.dev/v1/sub-accounts?limit=50" \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" curl -X PATCH https://api.zavu.dev/v1/sub-accounts/jx7abc123 \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "creditLimit": 250000 }' ``` ## Spending ```bash theme={null} curl https://api.zavu.dev/v1/sub-accounts/jx7abc123/balance \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` Returns the parent team's `balance` alongside this sub-account's `totalSpent` and `creditLimit`, all in cents. ## API keys ```bash theme={null} # List curl https://api.zavu.dev/v1/sub-accounts/jx7abc123/api-keys \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" # Create (the full key is returned once) curl -X POST https://api.zavu.dev/v1/sub-accounts/jx7abc123/api-keys \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Production Key", "environment": "live" }' # Revoke curl -X DELETE https://api.zavu.dev/v1/sub-accounts/jx7abc123/api-keys/key_id \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` ## Deactivate ```bash theme={null} curl -X DELETE https://api.zavu.dev/v1/sub-accounts/jx7abc123 \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` Remaining balance returns to the parent team and every API key is revoked. > [card] [Sub-accounts guide](/guides/sub-accounts/overview) > > When to use them, and how billing works > [card] [API reference](/api-reference/create-sub-account) > > Every field on the endpoints # Templates Source: https://docs.zavu.dev/sdks/php/templates Create, list and submit WhatsApp message templates via the Zavu PHP SDK. Manage variables, categories and approval status from your code. Templates are reusable message formats for sending structured messages across WhatsApp, SMS, Telegram, and other channels. WhatsApp requires template approval for business-initiated conversations. ## Create Template ```php theme={null} $result = $client->templates->create([ 'name' => 'order_confirmation', 'language' => 'en', 'body' => 'Hi {{1}}, your order #{{2}} is confirmed!', 'whatsappCategory' => 'UTILITY', 'variables' => ['customer_name', 'order_id'], ]); echo $result->id . "\n"; // tpl_xxx echo $result->status . "\n"; // draft, pending, approved, rejected ``` ## List Templates ```php theme={null} $result = $client->templates->list([]); foreach ($result->items as $template) { echo $template->id . " " . $template->name . " " . $template->status . "\n"; } // With pagination $result = $client->templates->list([ 'limit' => 50, 'cursor' => 'cursor_xxx', ]); ``` ## Get Template ```php theme={null} $result = $client->templates->retrieve([ 'templateId' => 'tpl_abc123', ]); echo $result->name . "\n"; echo $result->body . "\n"; echo $result->status . "\n"; ``` ## Delete Template ```php theme={null} $client->templates->delete([ 'templateId' => 'tpl_abc123', ]); ``` ## Submit Template for Approval After creating a template, you need to submit it to Meta for approval before you can use it: ```php theme={null} $result = $client->templates->submit([ 'templateId' => 'tpl_abc123', 'senderId' => 'sender_xyz', 'category' => 'UTILITY', // Optional if already set on template ]); echo $result->status . "\n"; // "pending" ``` > [!NOTE] > > The `senderId` must reference a sender that has a WhatsApp Business Account configured. Templates are submitted to the WABA associated with that sender. ## Template Categories | Category | Use Case | | - | - | | `UTILITY` | Order updates, account alerts, appointment reminders | | `MARKETING` | Promotions, offers, newsletters | | `AUTHENTICATION` | OTP codes, verification messages | ## Using Templates in Messages ```php theme={null} $result = $client->messages->send([ 'to' => '+14155551234', 'messageType' => 'template', 'content' => [ 'templateId' => 'tpl_abc123', 'templateVariables' => [ '1' => 'John', '2' => 'ORD-12345', ], ], ]); ``` # Broadcasts Source: https://docs.zavu.dev/sdks/python/broadcasts Create, schedule and track bulk message broadcasts via the Zavu Python SDK. Add contacts, send to thousands and monitor delivery progress. Broadcasts allow you to send messages to multiple recipients in a single campaign. ## Create Broadcast ```python theme={null} result = client.broadcasts.create( name="Black Friday Sale", channel="sms", text="Hi {{name}}, check out our Black Friday deals! Use code FRIDAY20 for 20% off." ) print(result.broadcast.id) # brd_xxx print(result.broadcast.status) # draft ``` ### WhatsApp Template Broadcast ```python theme={null} result = client.broadcasts.create( name="Order Confirmation Campaign", channel="whatsapp", message_type="template", content={ "template_id": "tpl_abc123" } ) ``` ### Email Broadcast ```python theme={null} result = client.broadcasts.create( name="Newsletter", channel="email", email_subject="Your Weekly Update", text="Hi {{name}}, here's what's new this week..." ) ``` ## Get Broadcast ```python theme={null} result = client.broadcasts.retrieve(broadcast_id="brd_abc123") print(result.broadcast.name) print(result.broadcast.status) print(result.broadcast.total_contacts) ``` ## List Broadcasts ```python theme={null} result = client.broadcasts.list() for broadcast in result.items: print(broadcast.id, broadcast.name, broadcast.status) # Filter by status result = client.broadcasts.list( status="completed", limit=50 ) ``` ## Update Broadcast Update a broadcast in draft status: ```python theme={null} result = client.broadcasts.update( broadcast_id="brd_abc123", name="Updated Campaign Name", text="New message content with {{name}}" ) ``` ## Add Contacts Add contacts to a broadcast (max 1000 per request): ```python theme={null} result = client.broadcasts.contacts.add( broadcast_id="brd_abc123", contacts=[ { "recipient": "+14155551234", "template_variables": { "name": "John", "order_id": "ORD-001" } }, { "recipient": "+14155555678", "template_variables": { "name": "Jane", "order_id": "ORD-002" } } ] ) print(result.added) # 2 print(result.duplicates) # 0 print(result.invalid) # 0 ``` ## List Broadcast Contacts ```python theme={null} result = client.broadcasts.contacts.list( broadcast_id="brd_abc123", status="pending", limit=100 ) for contact in result.items: print(contact.recipient, contact.status) ``` ## Remove Contact ```python theme={null} client.broadcasts.contacts.remove( broadcast_id="brd_abc123", contact_id="bcon_xyz789" ) ``` ## Send Broadcast Start sending immediately: ```python theme={null} result = client.broadcasts.send(broadcast_id="brd_abc123") print(result.broadcast.status) # sending ``` Schedule for later: ```python theme={null} result = client.broadcasts.send( broadcast_id="brd_abc123", scheduled_at="2024-01-15T10:00:00Z" ) print(result.broadcast.status) # scheduled ``` ## Reschedule Broadcast Change the scheduled time for a broadcast in `scheduled` status: ```python theme={null} result = client.broadcasts.reschedule( broadcast_id="brd_abc123", scheduled_at="2024-01-16T14:00:00Z" ) print(result.broadcast.scheduled_at) # 2024-01-16T14:00:00.000Z ``` > [!NOTE] > > You can only reschedule broadcasts that are in `scheduled` status. The new time must be in the future. ## Get Progress Monitor broadcast progress in real-time: ```python theme={null} result = client.broadcasts.progress(broadcast_id="brd_abc123") print(result.status) # sending print(result.total) # 5000 print(result.delivered) # 2350 print(result.failed) # 50 print(result.percent_complete) # 48.0 print(result.estimated_completion_at) ``` ## Cancel Broadcast ```python theme={null} result = client.broadcasts.cancel(broadcast_id="brd_abc123") print(result.broadcast.status) # cancelled ``` > [!WARNING] > > Cancelling a broadcast will skip pending contacts, but messages already queued may still be delivered. ## Delete Broadcast Delete a broadcast in draft status: ```python theme={null} client.broadcasts.delete(broadcast_id="brd_abc123") ``` ## Async Usage ```python theme={null} import asyncio from zavudev import AsyncZavudev async def main(): client = AsyncZavudev() result = await client.broadcasts.create( name="Async Campaign", channel="sms", text="Hello {{name}}!" ) print(result.broadcast.id) asyncio.run(main()) ``` # Contacts Source: https://docs.zavu.dev/sdks/python/contacts Create, list, retrieve and update contacts via the Zavu Python SDK. Manage profile metadata, channels and merge suggestions from your code. Contacts are automatically created when you send messages. You can retrieve and update contact information. ## Get Contact ```python theme={null} result = client.contacts.retrieve(contact_id="con_abc123") print(result.id) print(result.phone_number) print(result.country_code) print(result.available_channels) print(result.default_channel) ``` ## Get Contact by Phone ```python theme={null} result = client.contacts.retrieve_by_phone(phone_number="+14155551234") print(result.id) print(result.available_channels) # ["sms", "whatsapp"] ``` ## Update Contact ```python theme={null} result = client.contacts.update( contact_id="con_abc123", default_channel="whatsapp", metadata={ "name": "John Doe", "tier": "premium" } ) ``` ## List Contacts ```python theme={null} result = client.contacts.list(limit=50) for contact in result.items: print(contact.id, contact.phone_number) # Filter by phone number result = client.contacts.list(phone_number="+1415") # Pagination cursor = None while True: result = client.contacts.list(cursor=cursor, limit=50) for contact in result.items: print(contact.id) cursor = result.next_cursor if not cursor: break ``` ## Phone Introspection Validate a phone number and check available channels: ```python theme={null} result = client.introspect.validate_phone(phone_number="+14155551234") print(result.valid_number) # True print(result.country_code) # "US" print(result.national_format) # "(415) 555-1234" print(result.line_type) # "mobile" print(result.available_channels) # ["sms", "whatsapp"] print(result.carrier.name) # "Verizon Wireless" ``` # Invitations Source: https://docs.zavu.dev/sdks/python/invitations Partner invitations for client WhatsApp Business onboarding. Not yet generated in the Python SDK: use the REST API or the CLI. > [!WARNING] > > **Not generated in the Python SDK yet.** The `zavudev` package does not expose an `invitations` > resource today, so there is nothing to call. The endpoints exist and are stable: > use the REST API below, the [CLI](/guides/cli/overview), or the > [TypeScript SDK](/sdks/typescript/invitations), which does cover it. See the > [coverage table](/sdks/overview#coverage). Partner invitations generate a link a client opens to connect their own WhatsApp Business Account, or a Facebook Page, into your project. One invitation connects one channel. ## Create an invitation ```bash theme={null} curl -X POST https://api.zavu.dev/v1/invitations \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "clientName": "Acme Corp", "clientEmail": "contact@acme.com", "connectionType": "whatsapp_waba", "expiresInDays": 14 }' ``` The response carries `url`, the link you share with the client, and `token`. ```bash CLI theme={null} npx zavudev invitations create --data '{"clientName":"Acme Corp"}' ``` ## Retrieve an invitation ```bash theme={null} curl https://api.zavu.dev/v1/invitations/inv_abc123 \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` Poll `status` (`pending`, `in_progress`, `completed`, `expired`, `cancelled`, `failed`), or subscribe to the `invitation.status_changed` webhook instead of polling. A `failed` invitation is still usable: the same link can be retried. ## List invitations ```bash theme={null} curl "https://api.zavu.dev/v1/invitations?status=pending&limit=50" \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` ## Cancel an invitation ```bash theme={null} curl -X POST https://api.zavu.dev/v1/invitations/inv_abc123/cancel \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` ## After completion When the client finishes, the invitation moves to `completed` and carries `senderId` plus `connectedAccount` (the WhatsApp number or Facebook Page that was linked). That sender is now yours to send from. > [card] [Partner invitations guide](/guides/partner-invitations/api) > > The full flow, webhooks, and failure codes > [card] [API reference](/api-reference/create-invitation) > > Every field on the endpoint # Send Messages with the Python SDK Source: https://docs.zavu.dev/sdks/python/messages Send and manage SMS, WhatsApp, Email, and Voice messages with the Zavu Python SDK. Production-ready, fully typed, with helpers for retries and delivery ## Send a Message ```python theme={null} result = client.messages.send( to="+14155551234", text="Your verification code is 123456", channel="sms", # optional: sms, whatsapp, email idempotency_key="unique-key" # optional: prevent duplicates ) print(result.message.id) # msg_xxx print(result.message.status) # queued ``` ## Send with Sender Specify which sender profile to use: ```python theme={null} result = client.messages.send( to="+14155551234", text="Hello!", zavu_sender="snd_abc123" ) ``` ## Send with Template Send a WhatsApp template message: ```python theme={null} result = client.messages.send( to="+14155551234", message_type="template", content={ "template_id": "tpl_abc123", "template_variables": { "1": "John", "2": "12345" } } ) ``` ## Send WhatsApp Media ### Image ```python theme={null} result = client.messages.send( to="+14155551234", message_type="image", text="Check out this product!", content={ "media_url": "https://example.com/image.jpg" } ) ``` ### Document ```python theme={null} result = client.messages.send( to="+14155551234", message_type="document", content={ "media_url": "https://example.com/invoice.pdf", "filename": "invoice.pdf" } ) ``` ### Video ```python theme={null} result = client.messages.send( to="+14155551234", message_type="video", text="Watch this!", content={ "media_url": "https://example.com/video.mp4" } ) ``` ## Send Interactive Messages ### Buttons ```python theme={null} result = client.messages.send( to="+14155551234", message_type="buttons", text="How would you rate your experience?", content={ "buttons": [ {"id": "great", "title": "Great!"}, {"id": "okay", "title": "It was okay"}, {"id": "poor", "title": "Not good"} ] } ) ``` ### List ```python theme={null} result = client.messages.send( to="+14155551234", message_type="list", text="Select an option:", content={ "list_button": "View Options", "sections": [ { "title": "Products", "rows": [ {"id": "prod_1", "title": "Product A", "description": "$10.00"}, {"id": "prod_2", "title": "Product B", "description": "$20.00"} ] } ] } ) ``` ## Send Email ```python theme={null} result = client.messages.send( to="user@example.com", channel="email", subject="Your order has shipped", text="Hi John, your order #12345 has shipped.", html_body="

Order Shipped

Your order #12345 has shipped.

", reply_to="support@example.com" ) ``` ## Send Email with Attachments ```python theme={null} import base64 with open("invoice.pdf", "rb") as f: content = base64.b64encode(f.read()).decode() result = client.messages.send( to="user@example.com", channel="email", subject="Your invoice", text="Please find your invoice attached.", attachments=[ { "filename": "invoice.pdf", "content": content, "content_type": "application/pdf", } ], ) ``` ### Attachment from URL ```python theme={null} result = client.messages.send( to="user@example.com", channel="email", subject="Your report", text="Report attached.", attachments=[ { "filename": "report.pdf", "path": "https://your-server.com/reports/report.pdf", } ], ) ``` ### Inline Image ```python theme={null} result = client.messages.send( to="user@example.com", channel="email", subject="Weekly report", text="See your report.", html_body='

Report

', attachments=[ { "filename": "chart.png", "path": "https://your-server.com/chart.png", "content_type": "image/png", "content_id": "chart", } ], ) ``` ## Get Message Status ```python theme={null} result = client.messages.retrieve(message_id="msg_abc123") print(result.message.status) # queued, sending, sent, delivered, failed ``` ## List Messages ```python theme={null} # List all messages result = client.messages.list() for message in result.items: print(message.id) # With filters result = client.messages.list( status="delivered", channel="sms", limit=100 ) # Pagination cursor = None while True: result = client.messages.list(cursor=cursor, limit=50) for message in result.items: print(message.id) cursor = result.next_cursor if not cursor: break ``` ## Send Reaction React to a WhatsApp message: ```python theme={null} result = client.messages.react( message_id="msg_abc123", emoji="👍" ) ``` ## Async Usage ```python theme={null} import asyncio from zavudev import AsyncZavudev async def main(): client = AsyncZavudev() result = await client.messages.send( to="+14155551234", text="Hello async!" ) print(result.message.id) asyncio.run(main()) ``` # Python SDK Overview Source: https://docs.zavu.dev/sdks/python/overview Official Zavu SDK for Python. Send SMS, WhatsApp, Email and Voice messages from any Python app with retries, idempotency and full typing support. > [!WARNING] > > **Server-side only** — This SDK is designed for server-side environments (Python servers, serverless functions). Never use it in browser/frontend code as this exposes your API key. See [Frontend Integration](/authentication#frontend-integration) for the proxy pattern. > [!NOTE] > > **Coverage.** The Python SDK does not generate invitations, URL verification, sub-accounts, balance, account, functions and 10DLC yet, > and no SDK generates voice calls. Those endpoints are still reachable: > use the [CLI](/guides/cli/overview) or the REST API. See the > [coverage table](/sdks/overview#coverage). The official Zavu SDK for Python provides a clean, Pythonic interface to the Zavu API with full type hints. ## Installation ```bash theme={null} pip install zavudev # or uv add zavudev # or poetry add zavudev ``` ## Requirements * Python 3.9 or higher ## Quick Start ```python theme={null} import os from zavudev import Zavudev zavu = Zavudev( api_key=os.environ.get("ZAVUDEV_API_KEY"), ) result = zavu.messages.send( to="+14155551234", text="Hello from Zavu!" ) print(f"Message sent: {result.message.id}") ``` ## Configuration ### Initialize the Client ```python theme={null} from zavudev import Zavudev # Basic initialization zavu = Zavudev( api_key="zv_live_xxx", ) # With custom base URL zavu = Zavudev( api_key="zv_live_xxx", base_url="https://api.zavu.dev" ) ``` ### Environment Variables We recommend storing your API key in environment variables: ```python theme={null} import os from zavudev import Zavudev zavu = Zavudev( api_key=os.environ.get("ZAVUDEV_API_KEY"), ) ``` ## Accessing Response Headers Use `.with_raw_response()` to access the full HTTP response including headers: ```python theme={null} response = zavu.messages.send.with_raw_response( to="+14155551234", text="Hello!" ) remaining = response.headers.get("X-RateLimit-Remaining") print(f"Remaining requests: {remaining}") result = response.parse() print(f"Message ID: {result.message.id}") ``` ## Error Handling ```python theme={null} from zavudev import Zavudev, ZavudevError, APIError zavu = Zavudev( api_key=os.environ.get("ZAVUDEV_API_KEY"), ) try: zavu.messages.send(to="invalid-number", text="Hello") except APIError as e: print(f"Status: {e.status_code}") print(f"Message: {e.message}") except ZavudevError as e: print(f"Error: {e.message}") ``` ### Exception Types | Exception | Description | | - | - | | `ZavudevError` | Base exception class | | `APIError` | API error with status code | ## Async Support The SDK supports async/await with asyncio: ```python theme={null} import asyncio import os from zavudev import AsyncZavudev async def main(): zavu = AsyncZavudev( api_key=os.environ.get("ZAVUDEV_API_KEY"), ) result = await zavu.messages.send( to="+14155551234", text="Hello async!" ) print(f"Message sent: {result.message.id}") asyncio.run(main()) ``` ## Retries The SDK supports automatic retries with configurable backoff: ```python theme={null} from zavudev import Zavudev zavu = Zavudev( api_key="zv_live_xxx", max_retries=3, ) result = zavu.messages.send( to="+14155551234", text="Hello!" ) ``` ## Type Hints The SDK includes full type annotations: ```python theme={null} from zavudev import Zavudev from zavudev.types import Message zavu = Zavudev( api_key=os.environ.get("ZAVUDEV_API_KEY"), ) result = zavu.messages.send( to="+14155551234", text="Hello!" ) message: Message = result.message ``` ## Debugging Enable debug logging: ```python theme={null} import logging from zavudev import Zavudev logging.basicConfig(level=logging.DEBUG) zavu = Zavudev( api_key="zv_live_xxx", ) ``` # Phone Numbers Source: https://docs.zavu.dev/sdks/python/phone-numbers Search, purchase and assign phone numbers via the Zavu Python SDK. Manage SMS, WhatsApp and Voice numbers across countries with one API. Phone numbers are required for sending SMS and WhatsApp messages. You can search for available numbers, purchase them, and manage them through the API. ## Search Available Numbers Find phone numbers available for purchase: ```python theme={null} result = client.phone_numbers.search_available( country_code="US", type="local", limit=10 ) for number in result.items: print(number.phone_number, number.locality, number.region) print("Monthly:", number.pricing.monthly_price) print("Free eligible:", number.pricing.is_free_eligible) ``` ### Search Parameters ```python theme={null} result = client.phone_numbers.search_available( country_code="US", # Required: Two-letter country code type="local", # Optional: local, national, tollFree. The API also # takes mobile; this SDK does not type it yet, so use REST for it. contains="555", # Optional: Pattern to search for limit=20 # Optional: Max results (default: 10, max: 50) ) ``` ## Purchase a Phone Number ```python theme={null} phone_number = client.phone_numbers.purchase( phone_number="+14155551234", name="Customer Support" ) print(phone_number.phone_number.id) # pn_abc123 print(phone_number.phone_number.phone_number) # +14155551234 print(phone_number.phone_number.status) # active ``` > [!NOTE] > > Buying numbers requires a paid plan (`402 paid_plan_required` on Free). A paid plan includes one number at no charge, once per account: a US or Canadian number (a +1 number) costing \$20 a month or less; `is_free_eligible` in search results marks the number that qualifies. > [!NOTE] > > Some numbers require regulatory information before they can be used; the purchase checks the exact number before charging anything. The SDK does not send `type` or `regulatoryRequirements` yet: for those numbers use the REST flow in [Regulatory Requirements](/guides/phone-numbers/regulatory-requirements), then poll the number's regulatory status until it is `approved`. A sender can be assigned before or after approval. ## List Phone Numbers ```python theme={null} result = client.phone_numbers.list() for number in result.items: print(number.id, number.phone_number, number.name) print("Status:", number.status) print("Assigned to:", number.sender_id) # With filters result = client.phone_numbers.list( status="active", limit=50, cursor="cursor_xxx" ) ``` ## Get Phone Number ```python theme={null} number = client.phone_numbers.retrieve("pn_abc123").phone_number print(number.phone_number) print(number.name) print(number.capabilities) print(number.pricing.monthly_price) print(number.next_renewal_date) ``` ## Update Phone Number Update the name or sender assignment: ```python theme={null} # Update name client.phone_numbers.update("pn_abc123", name="Marketing Line") # Assign to a sender client.phone_numbers.update("pn_abc123", sender_id="snd_xyz789") # Unassign from sender client.phone_numbers.update("pn_abc123", sender_id=None) ``` ## Release Phone Number Release a phone number you no longer need: ```python theme={null} client.phone_numbers.release("pn_abc123") ``` > [!WARNING] > > You cannot release a phone number assigned to a sender. Unassign it first. ## Response Types ### Phone Number Object ```python theme={null} class PhoneNumber: id: str phone_number: str name: Optional[str] capabilities: List[str] # ["sms", "voice", "mms"] status: Literal["active", "suspended", "pending"] sender_id: Optional[str] pricing: PhoneNumberPricing next_renewal_date: Optional[str] created_at: str updated_at: Optional[str] class PhoneNumberPricing: monthly_price: float upfront_cost: int monthly_cost: int is_free_number: bool ``` ### Available Phone Number Object ```python theme={null} class AvailablePhoneNumber: phone_number: str friendly_name: Optional[str] locality: Optional[str] region: Optional[str] capabilities: PhoneNumberCapabilities pricing: AvailablePricing class PhoneNumberCapabilities: sms: bool voice: bool mms: bool class AvailablePricing: monthly_price: float upfront_price: float is_free_eligible: bool ``` ## Error Handling ```python theme={null} import zavudev try: client.phone_numbers.purchase(phone_number="+14155551234") except zavudev.APIStatusError as e: code = e.body.get("code") if isinstance(e.body, dict) else None if code == "insufficient_balance": print("Add funds to your account") elif code == "number_unavailable": print("Phone number is no longer available") elif code == "regulatory_compliance_required": print("This number needs regulatory information") ``` # Manage Senders with the Python SDK Source: https://docs.zavu.dev/sdks/python/senders Create, update and list sender profiles via the Zavu Python SDK. Combine phone numbers, WhatsApp accounts and webhooks into routing identities. Senders are phone numbers or identities used to send messages. Each project can have multiple senders with one designated as default. ## List Senders ```python theme={null} result = client.senders.list() for sender in result.items: print(sender.id, sender.name, sender.phone_number) if sender.is_default: print("(Default sender)") # With pagination result = client.senders.list( limit=50, cursor="cursor_xxx" ) ``` ## Get Sender ```python theme={null} result = client.senders.retrieve(sender_id="snd_abc123") print(result.id) print(result.name) print(result.phone_number) print(result.is_default) ``` ## Create Sender ```python theme={null} result = client.senders.create( name="Marketing", phone_number="+15551234567", set_as_default=True ) print(result.id) # snd_xxx ``` ### With Webhook ```python theme={null} result = client.senders.create( name="Marketing", phone_number="+15551234567", webhook_url="https://your-app.com/webhooks", webhook_events=["message.inbound", "message.delivered", "message.failed"] ) # Webhook secret is only returned on create if result.webhook and result.webhook.secret: print(f"Save this secret: {result.webhook.secret}") ``` ## Update Sender ```python theme={null} result = client.senders.update( sender_id="snd_abc123", name="Support", set_as_default=True ) ``` ### Update Webhook Configuration ```python theme={null} result = client.senders.update( sender_id="snd_abc123", webhook_url="https://new-url.com/webhooks", webhook_events=["message.inbound"], webhook_active=True ) ``` ### Disable Webhook ```python theme={null} result = client.senders.update( sender_id="snd_abc123", webhook_active=False ) ``` ### Remove Webhook ```python theme={null} result = client.senders.update( sender_id="snd_abc123", webhook_url=None ) ``` ## Delete Sender ```python theme={null} client.senders.delete(sender_id="snd_abc123") ``` > [!NOTE] > > You cannot delete the default sender. Set another sender as default first. ## Regenerate Webhook Secret If your webhook secret is compromised, generate a new one: ```python theme={null} result = client.senders.regenerate_webhook_secret( sender_id="snd_abc123" ) print(f"New secret: {result.secret}") ``` > [!WARNING] > > After regenerating the secret, update your webhook handler to use the new secret. The old secret will no longer work. ## WhatsApp Business Profile Manage the WhatsApp Business Profile for senders with a connected WhatsApp Business Account. ### Get Profile ```python theme={null} response = client.senders.get_profile(sender_id="snd_abc123") print(response.profile.about) print(response.profile.description) print(response.profile.address) print(response.profile.email) print(response.profile.websites) print(response.profile.vertical) print(response.profile.profile_picture_url) ``` ### Update Profile ```python theme={null} response = client.senders.update_profile( sender_id="snd_abc123", about="Your trusted online store", description="Best products at competitive prices", address="123 Main St, San Francisco, CA", email="support@example.com", websites=["https://example.com"], vertical="RETAIL" ) ``` ### Upload Profile Picture ```python theme={null} response = client.senders.upload_profile_picture( sender_id="snd_abc123", image_url="https://example.com/logo.png", mime_type="image/png" ) ``` > [!NOTE] > > Profile methods are only available for senders with a WhatsApp Business Account connected. ## Using Senders in Messages Specify a sender when sending a message: ```python theme={null} result = client.messages.send( to="+14155551234", text="Hello!", zavu_sender="snd_abc123" ) ``` If no sender is specified, the default sender is used. # Sub-Accounts Source: https://docs.zavu.dev/sdks/python/sub-accounts Isolated sub-accounts with their own API keys and spend limits. Not yet generated in the Python SDK: use the REST API or the CLI. > [!WARNING] > > **Not generated in the Python SDK yet.** The `zavudev` package does not expose a `sub-accounts` > resource today, so there is nothing to call. The endpoints exist and are stable: > use the REST API below, the [CLI](/guides/cli/overview), or the > [TypeScript SDK](/sdks/typescript/sub-accounts), which does cover it. See the > [coverage table](/sdks/overview#coverage). Sub-accounts are isolated projects under your team, each with its own API keys and spending cap. Every charge still bills to the parent team's balance. > [!NOTE] > > These endpoints require a **parent project** API key. A sub-account key calling > them receives `403`. ## Create a sub-account ```bash theme={null} curl -X POST https://api.zavu.dev/v1/sub-accounts \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Client ABC", "externalId": "client_123", "creditLimit": 100000 }' ``` `creditLimit` is in cents. The response includes `apiKey`, and that is the only time it is returned. ```bash CLI theme={null} npx zavudev sub-accounts create --data '{"name":"Client ABC"}' ``` ## Retrieve, list and update ```bash theme={null} curl https://api.zavu.dev/v1/sub-accounts/jx7abc123 \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" curl "https://api.zavu.dev/v1/sub-accounts?limit=50" \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" curl -X PATCH https://api.zavu.dev/v1/sub-accounts/jx7abc123 \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "creditLimit": 250000 }' ``` ## Spending ```bash theme={null} curl https://api.zavu.dev/v1/sub-accounts/jx7abc123/balance \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` Returns the parent team's `balance` alongside this sub-account's `totalSpent` and `creditLimit`, all in cents. ## API keys ```bash theme={null} # List curl https://api.zavu.dev/v1/sub-accounts/jx7abc123/api-keys \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" # Create (the full key is returned once) curl -X POST https://api.zavu.dev/v1/sub-accounts/jx7abc123/api-keys \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Production Key", "environment": "live" }' # Revoke curl -X DELETE https://api.zavu.dev/v1/sub-accounts/jx7abc123/api-keys/key_id \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` ## Deactivate ```bash theme={null} curl -X DELETE https://api.zavu.dev/v1/sub-accounts/jx7abc123 \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` Remaining balance returns to the parent team and every API key is revoked. > [card] [Sub-accounts guide](/guides/sub-accounts/overview) > > When to use them, and how billing works > [card] [API reference](/api-reference/create-sub-account) > > Every field on the endpoints # Templates Source: https://docs.zavu.dev/sdks/python/templates Create, list and submit WhatsApp message templates via the Zavu Python SDK. Manage variables, categories and approval status from your code. Templates are reusable message formats for sending structured messages across WhatsApp, SMS, Telegram, and other channels. WhatsApp requires template approval for business-initiated conversations. ## Create Template ```python theme={null} result = client.templates.create( name="order_confirmation", language="en", body="Hi {{1}}, your order #{{2}} is confirmed!", whatsapp_category="UTILITY", variables=["customer_name", "order_id"] ) print(result.id) # tpl_xxx print(result.status) # draft, pending, approved, rejected ``` ## List Templates ```python theme={null} result = client.templates.list() for template in result.items: print(template.id, template.name, template.status) # With pagination result = client.templates.list( limit=50, cursor="cursor_xxx" ) ``` ## Get Template ```python theme={null} result = client.templates.retrieve(template_id="tpl_abc123") print(result.name) print(result.body) print(result.status) ``` ## Delete Template ```python theme={null} client.templates.delete(template_id="tpl_abc123") ``` ## Submit Template for Approval After creating a template, you need to submit it to Meta for approval before you can use it: ```python theme={null} result = client.templates.submit( template_id="tpl_abc123", sender_id="sender_xyz", category="UTILITY" # Optional if already set on template ) print(result.status) # "pending" ``` > [!NOTE] > > The `sender_id` must reference a sender that has a WhatsApp Business Account configured. Templates are submitted to the WABA associated with that sender. ## Template Categories | Category | Use Case | | - | - | | `UTILITY` | Order updates, account alerts, appointment reminders | | `MARKETING` | Promotions, offers, newsletters | | `AUTHENTICATION` | OTP codes, verification messages | ## Using Templates in Messages ```python theme={null} result = client.messages.send( to="+14155551234", message_type="template", content={ "template_id": "tpl_abc123", "template_variables": { "1": "John", "2": "ORD-12345" } } ) ``` # Broadcasts Source: https://docs.zavu.dev/sdks/ruby/broadcasts Create, schedule and track bulk message broadcasts via the Zavu Ruby SDK. Add contacts, send to thousands and monitor delivery progress. Broadcasts allow you to send messages to multiple recipients in a single campaign. ## Create Broadcast ```ruby theme={null} result = client.broadcasts.create( name: "Black Friday Sale", channel: "sms", text: "Hi {{name}}, check out our Black Friday deals! Use code FRIDAY20 for 20% off." ) puts result.broadcast.id # brd_xxx puts result.broadcast.status # draft ``` ### WhatsApp Template Broadcast ```ruby theme={null} result = client.broadcasts.create( name: "Order Confirmation Campaign", channel: "whatsapp", message_type: "template", content: { "template_id" => "tpl_abc123" } ) ``` ### Email Broadcast ```ruby theme={null} result = client.broadcasts.create( name: "Newsletter", channel: "email", email_subject: "Your Weekly Update", text: "Hi {{name}}, here's what's new this week..." ) ``` ## Get Broadcast ```ruby theme={null} result = client.broadcasts.retrieve(broadcast_id: "brd_abc123") puts result.broadcast.name puts result.broadcast.status puts result.broadcast.total_contacts ``` ## List Broadcasts ```ruby theme={null} result = client.broadcasts.list result.items.each do |broadcast| puts "#{broadcast.id} #{broadcast.name} #{broadcast.status}" end # Filter by status result = client.broadcasts.list( status: "completed", limit: 50 ) ``` ## Update Broadcast Update a broadcast in draft status: ```ruby theme={null} result = client.broadcasts.update( broadcast_id: "brd_abc123", name: "Updated Campaign Name", text: "New message content with {{name}}" ) ``` ## Add Contacts Add contacts to a broadcast (max 1000 per request): ```ruby theme={null} result = client.broadcasts.contacts.add( broadcast_id: "brd_abc123", contacts: [ { "recipient" => "+14155551234", "template_variables" => { "name" => "John", "order_id" => "ORD-001" } }, { "recipient" => "+14155555678", "template_variables" => { "name" => "Jane", "order_id" => "ORD-002" } } ] ) puts result.added # 2 puts result.duplicates # 0 puts result.invalid # 0 ``` ## List Broadcast Contacts ```ruby theme={null} result = client.broadcasts.contacts.list( broadcast_id: "brd_abc123", status: "pending", limit: 100 ) result.items.each do |contact| puts "#{contact.recipient} #{contact.status}" end ``` ## Remove Contact ```ruby theme={null} client.broadcasts.contacts.remove( broadcast_id: "brd_abc123", contact_id: "bcon_xyz789" ) ``` ## Send Broadcast Start sending immediately: ```ruby theme={null} result = client.broadcasts.send_broadcast( broadcast_id: "brd_abc123" ) puts result.broadcast.status # sending ``` Schedule for later: ```ruby theme={null} result = client.broadcasts.send_broadcast( broadcast_id: "brd_abc123", scheduled_at: "2024-01-15T10:00:00Z" ) puts result.broadcast.status # scheduled ``` ## Reschedule Broadcast Change the scheduled time for a broadcast in `scheduled` status: ```ruby theme={null} result = client.broadcasts.reschedule( broadcast_id: "brd_abc123", scheduled_at: "2024-01-16T14:00:00Z" ) puts result.broadcast.scheduled_at # 2024-01-16T14:00:00.000Z ``` > [!NOTE] > > You can only reschedule broadcasts that are in `scheduled` status. The new time must be in the future. ## Get Progress Monitor broadcast progress in real-time: ```ruby theme={null} result = client.broadcasts.progress(broadcast_id: "brd_abc123") puts result.status # sending puts result.total # 5000 puts result.delivered # 2350 puts result.failed # 50 puts result.percent_complete # 48.0 puts result.estimated_completion_at ``` ## Cancel Broadcast ```ruby theme={null} result = client.broadcasts.cancel(broadcast_id: "brd_abc123") puts result.broadcast.status # cancelled ``` > [!WARNING] > > Cancelling a broadcast will skip pending contacts, but messages already queued may still be delivered. ## Delete Broadcast Delete a broadcast in draft status: ```ruby theme={null} client.broadcasts.delete(broadcast_id: "brd_abc123") ``` # Contacts Source: https://docs.zavu.dev/sdks/ruby/contacts Create, list, retrieve and update contacts via the Zavu Ruby SDK. Manage profile metadata, channels and merge suggestions from your code. Contacts are automatically created when you send messages. You can retrieve and update contact information. ## Get Contact ```ruby theme={null} result = client.contacts.retrieve(contact_id: "con_abc123") puts result.id puts result.phone_number puts result.country_code puts result.available_channels puts result.default_channel ``` ## Get Contact by Phone ```ruby theme={null} result = client.contacts.retrieve_by_phone(phone_number: "+14155551234") puts result.id puts result.available_channels # ["sms", "whatsapp"] ``` ## Update Contact ```ruby theme={null} result = client.contacts.update( contact_id: "con_abc123", default_channel: "whatsapp", metadata: { "name" => "John Doe", "tier" => "premium" } ) ``` ## List Contacts ```ruby theme={null} result = client.contacts.list(limit: 50) result.items.each do |contact| puts "#{contact.id} #{contact.phone_number}" end # Filter by phone number result = client.contacts.list(phone_number: "+1415") # Pagination cursor = nil loop do result = client.contacts.list(cursor: cursor, limit: 50) result.items.each { |contact| puts contact.id } cursor = result.next_cursor break if cursor.nil? end ``` ## Phone Introspection Validate a phone number and check available channels: ```ruby theme={null} result = client.introspect.validate_phone(phone_number: "+14155551234") puts result.valid_number # true puts result.country_code # "US" puts result.national_format # "(415) 555-1234" puts result.line_type # "mobile" puts result.available_channels # ["sms", "whatsapp"] puts result.carrier&.name # "Verizon Wireless" ``` # Invitations Source: https://docs.zavu.dev/sdks/ruby/invitations Generate partner invitations for client WhatsApp Business onboarding via the Zavu Ruby SDK. Track status and manage expiration. Partner invitations allow you to generate links for clients to connect their WhatsApp Business accounts to your project. ## Create Invitation ```ruby theme={null} invitation = client.invitations.create( client_name: "Acme Corp", client_email: "contact@acme.com", expires_in_days: 14 ) puts invitation.id # jh7am5bng9p3v2x1k4r8 puts invitation.url # https://dashboard.zavu.dev/invite/abc123xyz puts invitation.status # pending ``` ### With Phone Number Restrictions Restrict which countries the client can register phone numbers from: ```ruby theme={null} invitation = client.invitations.create( client_name: "Acme Corp", client_email: "contact@acme.com", expires_in_days: 7, allowed_phone_countries: ["US", "MX"] ) ``` ## Get Invitation ```ruby theme={null} result = client.invitations.retrieve(invitation_id: "jh7am5bng9p3v2x1k4r8") puts result.invitation.status # pending | in_progress | completed | expired | cancelled puts result.invitation.client_name # Acme Corp puts result.invitation.sender_id # nil (until completed) puts result.invitation.expires_at # 2025-01-15T00:00:00.000Z ``` ## List Invitations ```ruby theme={null} result = client.invitations.list result.items.each do |invitation| puts "#{invitation.id} #{invitation.client_name} #{invitation.status}" end ``` ### Filter by Status ```ruby theme={null} result = client.invitations.list( status: "pending", limit: 50 ) ``` ### Pagination ```ruby theme={null} cursor = nil loop do result = client.invitations.list( limit: 50, cursor: cursor ) result.items.each { |invitation| puts invitation.id } cursor = result.next_cursor break if cursor.nil? end ``` ## Cancel Invitation Cancel an active invitation to prevent the client from using it: ```ruby theme={null} result = client.invitations.cancel(invitation_id: "jh7am5bng9p3v2x1k4r8") puts result.invitation.status # cancelled ``` > [!NOTE] > > You cannot cancel a completed invitation. Once a sender is created, manage it through the senders API. ## After Completion When a client completes the signup flow, a sender is created in your project: ```ruby theme={null} result = client.invitations.retrieve(invitation_id: "jh7am5bng9p3v2x1k4r8") if result.invitation.status == "completed" && result.invitation.sender_id # Use the sender to send messages client.messages.send_( to: "+14155551234", channel: "whatsapp", message_type: "template", content: { "template_id" => "tmpl_xyz789", "template_variables" => { "1" => "John" } }, zavu_sender: result.invitation.sender_id ) end ``` ## Full Example ```ruby theme={null} require "zavudev" client = Zavudev::Client.new( api_key: ENV["ZAVUDEV_API_KEY"] ) def onboard_client(client, client_name, client_email) # Create invitation invitation = client.invitations.create( client_name: client_name, client_email: client_email, expires_in_days: 7 ) puts "Send this link to #{client_name}: #{invitation.url}" invitation end def check_invitation_status(client, invitation_id) result = client.invitations.retrieve(invitation_id: invitation_id) invitation = result.invitation case invitation.status when "pending" puts "Waiting for client to start signup" when "in_progress" puts "Client is completing the signup flow" when "completed" puts "Success! Sender ID: #{invitation.sender_id}" when "expired" puts "Invitation expired, create a new one" when "cancelled" puts "Invitation was cancelled" end invitation end # Usage invitation = onboard_client(client, "Acme Corp", "contact@acme.com") # Later... check_invitation_status(client, invitation.id) ``` # Send Messages with the Ruby SDK Source: https://docs.zavu.dev/sdks/ruby/messages Send and manage SMS, WhatsApp, Email, and Voice messages with the Zavu Ruby SDK. Production-ready, idiomatic Ruby API with retries and delivery tracking. ## Send a Message ```ruby theme={null} result = client.messages.send_( to: "+14155551234", text: "Your verification code is 123456", channel: "sms", # optional: sms, whatsapp, email metadata: { "userId" => "user_123" }, idempotency_key: "unique-key" # optional: prevent duplicates ) puts result.message.id # msg_xxx puts result.message.status # queued ``` ## Send with Sender Specify which sender profile to use: ```ruby theme={null} result = client.messages.send_( to: "+14155551234", text: "Hello!", zavu_sender: "snd_abc123" ) ``` ## Send with Template Send a WhatsApp template message: ```ruby theme={null} result = client.messages.send_( to: "+14155551234", message_type: "template", content: { "template_id" => "tpl_abc123", "template_variables" => { "1" => "John", "2" => "12345" } } ) ``` ## Send WhatsApp Media ### Image ```ruby theme={null} result = client.messages.send_( to: "+14155551234", message_type: "image", text: "Check out this product!", content: { "media_url" => "https://example.com/image.jpg" } ) ``` ### Document ```ruby theme={null} result = client.messages.send_( to: "+14155551234", message_type: "document", content: { "media_url" => "https://example.com/invoice.pdf", "filename" => "invoice.pdf" } ) ``` ### Video ```ruby theme={null} result = client.messages.send_( to: "+14155551234", message_type: "video", text: "Watch this!", content: { "media_url" => "https://example.com/video.mp4" } ) ``` ## Send Interactive Messages ### Buttons ```ruby theme={null} result = client.messages.send_( to: "+14155551234", message_type: "buttons", text: "How would you rate your experience?", content: { "buttons" => [ { "id" => "great", "title" => "Great!" }, { "id" => "okay", "title" => "It was okay" }, { "id" => "poor", "title" => "Not good" } ] } ) ``` ### List ```ruby theme={null} result = client.messages.send_( to: "+14155551234", message_type: "list", text: "Select an option:", content: { "list_button" => "View Options", "sections" => [ { "title" => "Products", "rows" => [ { "id" => "prod_1", "title" => "Product A", "description" => "$10.00" }, { "id" => "prod_2", "title" => "Product B", "description" => "$20.00" } ] } ] } ) ``` ## Send Email ```ruby theme={null} result = client.messages.send_( to: "user@example.com", channel: "email", subject: "Your order has shipped", text: "Hi John, your order #12345 has shipped.", html_body: "

Order Shipped

Your order #12345 has shipped.

", reply_to: "support@example.com" ) ``` ## Send Email with Attachments ```ruby theme={null} require "base64" content = Base64.strict_encode64(File.read("invoice.pdf")) result = client.messages.send_( to: "user@example.com", channel: "email", subject: "Your invoice", text: "Please find your invoice attached.", attachments: [ { filename: "invoice.pdf", content: content, content_type: "application/pdf", } ] ) ``` ### Attachment from URL ```ruby theme={null} result = client.messages.send_( to: "user@example.com", channel: "email", subject: "Your report", text: "Report attached.", attachments: [ { filename: "report.pdf", path: "https://your-server.com/reports/report.pdf", } ] ) ``` ## Get Message Status ```ruby theme={null} result = client.messages.retrieve(message_id: "msg_abc123") puts result.message.status # queued, sending, sent, delivered, failed ``` ## List Messages ```ruby theme={null} # List all messages result = client.messages.list result.items.each { |message| puts message.id } # With filters result = client.messages.list( status: "delivered", channel: "sms", limit: 100 ) # Pagination cursor = nil loop do result = client.messages.list(cursor: cursor, limit: 50) result.items.each { |message| puts message.id } cursor = result.next_cursor break if cursor.nil? end ``` ## Send Reaction React to a WhatsApp message: ```ruby theme={null} result = client.messages.react( message_id: "msg_abc123", emoji: "\u{1F44D}" ) ``` # Ruby SDK Overview Source: https://docs.zavu.dev/sdks/ruby/overview Official Zavu SDK for Ruby. Send SMS, WhatsApp, Email and Voice messages from Rails or any Ruby app with idiomatic helpers, retries and idempotency. > [!WARNING] > > **Server-side only** — This SDK is designed for server-side environments (Rails, Sinatra, background jobs). Never use it in browser/frontend code as this exposes your API key. See [Frontend Integration](/authentication#frontend-integration) for the proxy pattern. > [!NOTE] > > **Coverage.** This SDK tracks the API closely. Voice calls are the one area not > generated in any SDK yet: use the [CLI](/guides/cli/overview) or the REST API > for those. Note that `send` is taken by `Object#send` in Ruby, so the generated > method is `messages.send_`. See the [coverage table](/sdks/overview#coverage). The official Zavu SDK for Ruby provides an idiomatic Ruby interface to the Zavu API. ## Installation ```bash theme={null} gem install zavudev # or add to your Gemfile gem "zavudev" ``` ## Requirements * Ruby 3.0 or higher ## Quick Start ```ruby theme={null} require "zavudev" client = Zavudev::Client.new( api_key: ENV["ZAVUDEV_API_KEY"] ) result = client.messages.send_( to: "+14155551234", text: "Hello from Zavu!" ) puts "Message sent: #{result.message.id}" ``` ## Configuration ### Initialize the Client ```ruby theme={null} require "zavudev" # Basic initialization client = Zavudev::Client.new( api_key: ENV["ZAVUDEV_API_KEY"] ) # With custom base URL client = Zavudev::Client.new( api_key: "zv_live_xxx", base_url: "https://api.zavu.dev" ) ``` ### Environment Variables We recommend storing your API key in environment variables: ```ruby theme={null} client = Zavudev::Client.new( api_key: ENV["ZAVUDEV_API_KEY"] ) ``` ## Accessing Response Headers Use `.with_raw_response` to access the full HTTP response including headers (useful for rate limiting): ```ruby theme={null} response = client.messages.send.with_raw_response( to: "+14155551234", text: "Hello!" ) remaining = response.headers["X-RateLimit-Remaining"] puts "Remaining requests: #{remaining}" result = response.parse puts "Message ID: #{result.message.id}" ``` ## Error Handling ```ruby theme={null} require "zavudev" client = Zavudev::Client.new( api_key: ENV["ZAVUDEV_API_KEY"] ) begin client.messages.send_(to: "invalid-number", text: "Hello") rescue Zavudev::Errors::APIError => e puts "Status: #{e.status}" puts "Message: #{e.message}" rescue Zavudev::Errors::Error => e puts "Error: #{e.message}" end ``` ### Error Types | Error Code | Description | | - | - | | `400` | Invalid request parameters | | `401` | Invalid or missing API key | | `403` | Insufficient permissions | | `404` | Resource not found | | `429` | Rate limit exceeded | ## Retries The SDK supports automatic retries with configurable backoff: ```ruby theme={null} client = Zavudev::Client.new( api_key: "zv_live_xxx", max_retries: 3 ) result = client.messages.send_( to: "+14155551234", text: "Hello!" ) ``` ## Thread Safety The client is thread-safe and can be shared across threads: ```ruby theme={null} # Create a single client instance $zavu = Zavudev::Client.new( api_key: ENV["ZAVUDEV_API_KEY"] ) # Safe to use from multiple threads threads = 10.times.map do |i| Thread.new do $zavu.messages.send_( to: "+14155551234", text: "Message #{i}" ) end end threads.each(&:join) ``` ## Rails Integration ```ruby theme={null} # config/initializers/zavu.rb require "zavudev" ZAVU_CLIENT = Zavudev::Client.new( api_key: Rails.application.credentials.dig(:zavu, :api_key) ) ``` ```ruby theme={null} # app/services/notification_service.rb class NotificationService def send_sms(phone_number, message) ZAVU_CLIENT.messages.send_( to: phone_number, text: message ) end end ``` # Phone Numbers Source: https://docs.zavu.dev/sdks/ruby/phone-numbers Search, purchase and assign phone numbers via the Zavu Ruby SDK. Manage SMS, WhatsApp and Voice numbers across countries with one API. Phone numbers are required for sending SMS and WhatsApp messages. You can search for available numbers, purchase them, and manage them through the API. ## Search Available Numbers Find phone numbers available for purchase: ```ruby theme={null} result = client.phone_numbers.search_available( country_code: "US", type: "local", limit: 10 ) result.items.each do |number| puts "#{number.phone_number} #{number.locality} #{number.region}" puts "Monthly: #{number.pricing.monthly_price}" puts "Free eligible: #{number.pricing.is_free_eligible}" end ``` ### Search Parameters ```ruby theme={null} result = client.phone_numbers.search_available( country_code: "US", # Required: Two-letter country code type: "local", # Optional: local, national, tollFree. The API also # takes mobile; this SDK does not type it yet, so use REST for it. contains: "555", # Optional: Pattern to search for limit: 20 # Optional: Max results (default: 10, max: 50) ) ``` ## Purchase a Phone Number ```ruby theme={null} phone_number = client.phone_numbers.purchase( phone_number: "+14155551234", name: "Customer Support" ) puts phone_number.phone_number.id # pn_abc123 puts phone_number.phone_number.phone_number # +14155551234 puts phone_number.phone_number.status # active ``` > [!NOTE] > > Buying numbers requires a paid plan (`402 paid_plan_required` on Free). A paid plan includes one number at no charge, once per account: a US or Canadian number (a +1 number) costing \$20 a month or less; `is_free_eligible` in search results marks the number that qualifies. > [!NOTE] > > Some numbers require regulatory information before they can be used; the purchase checks the exact number before charging anything. The SDK does not send `type` or `regulatoryRequirements` yet: for those numbers use the REST flow in [Regulatory Requirements](/guides/phone-numbers/regulatory-requirements), then poll the number's regulatory status until it is `approved`. A sender can be assigned before or after approval. ## List Phone Numbers ```ruby theme={null} result = client.phone_numbers.list result.items.each do |number| puts "#{number.id} #{number.phone_number} #{number.name}" puts "Status: #{number.status}" puts "Assigned to: #{number.sender_id}" end # With filters result = client.phone_numbers.list( status: "active", limit: 50, cursor: "cursor_xxx" ) ``` ## Get Phone Number ```ruby theme={null} number = client.phone_numbers.retrieve("pn_abc123").phone_number puts number.phone_number puts number.name puts number.capabilities puts number.pricing.monthly_price puts number.next_renewal_date ``` ## Update Phone Number Update the name or sender assignment: ```ruby theme={null} # Update name client.phone_numbers.update("pn_abc123", name: "Marketing Line") # Assign to a sender client.phone_numbers.update("pn_abc123", sender_id: "snd_xyz789") # Unassign from sender client.phone_numbers.update("pn_abc123", sender_id: nil) ``` ## Release Phone Number Release a phone number you no longer need: ```ruby theme={null} client.phone_numbers.release("pn_abc123") ``` > [!WARNING] > > You cannot release a phone number assigned to a sender. Unassign it first. ## Error Handling ```ruby theme={null} begin number = client.phone_numbers.purchase( phone_number: "+14155551234" ) rescue Zavudev::Errors::APIError => e case e.status when 402 puts "Add funds to your account" when 400 puts "Invalid request, number unavailable, or regulatory information needed: #{e.body}" else puts "Error: #{e.message}" end end ``` # Manage Senders with the Ruby SDK Source: https://docs.zavu.dev/sdks/ruby/senders Create, update and list sender profiles via the Zavu Ruby SDK. Combine phone numbers, WhatsApp accounts and webhooks into routing identities. Senders are phone numbers or identities used to send messages. Each project can have multiple senders with one designated as default. ## List Senders ```ruby theme={null} result = client.senders.list result.items.each do |sender| puts "#{sender.id} #{sender.name} #{sender.phone_number}" puts "(Default sender)" if sender.is_default end # With pagination result = client.senders.list( limit: 50, cursor: "cursor_xxx" ) ``` ## Get Sender ```ruby theme={null} result = client.senders.retrieve(sender_id: "snd_abc123") puts result.id puts result.name puts result.phone_number puts result.is_default ``` ## Create Sender ```ruby theme={null} result = client.senders.create( name: "Marketing", phone_number: "+15551234567", set_as_default: true ) puts result.id # snd_xxx ``` ### With Webhook ```ruby theme={null} result = client.senders.create( name: "Marketing", phone_number: "+15551234567", webhook_url: "https://your-app.com/webhooks", webhook_events: ["message.inbound", "message.delivered", "message.failed"] ) # Webhook secret is only returned on create if result.webhook&.secret puts "Save this secret: #{result.webhook.secret}" end ``` ## Update Sender ```ruby theme={null} result = client.senders.update( sender_id: "snd_abc123", name: "Support", set_as_default: true ) ``` ### Update Webhook Configuration ```ruby theme={null} result = client.senders.update( sender_id: "snd_abc123", webhook_url: "https://new-url.com/webhooks", webhook_events: ["message.inbound"], webhook_active: true ) ``` ### Disable Webhook ```ruby theme={null} result = client.senders.update( sender_id: "snd_abc123", webhook_active: false ) ``` ### Remove Webhook ```ruby theme={null} result = client.senders.update( sender_id: "snd_abc123", webhook_url: nil ) ``` ## Delete Sender ```ruby theme={null} client.senders.delete(sender_id: "snd_abc123") ``` > [!NOTE] > > You cannot delete the default sender. Set another sender as default first. ## Regenerate Webhook Secret If your webhook secret is compromised, generate a new one: ```ruby theme={null} result = client.senders.regenerate_webhook_secret( sender_id: "snd_abc123" ) puts "New secret: #{result.secret}" ``` > [!WARNING] > > After regenerating the secret, update your webhook handler to use the new secret. The old secret will no longer work. ## WhatsApp Business Profile Manage the WhatsApp Business Profile for senders with a connected WhatsApp Business Account. ### Get Profile ```ruby theme={null} response = client.senders.get_profile("snd_abc123") puts response.profile.about puts response.profile.description puts response.profile.address puts response.profile.email puts response.profile.websites puts response.profile.vertical puts response.profile.profile_picture_url ``` ### Update Profile ```ruby theme={null} response = client.senders.update_profile("snd_abc123", about: "Your trusted online store", description: "Best products at competitive prices", address: "123 Main St, San Francisco, CA", email: "support@example.com", websites: ["https://example.com"], vertical: "RETAIL" ) ``` ### Upload Profile Picture ```ruby theme={null} response = client.senders.upload_profile_picture("snd_abc123", image_url: "https://example.com/logo.png", mime_type: "image/png" ) ``` > [!NOTE] > > Profile methods are only available for senders with a WhatsApp Business Account connected. ## Using Senders in Messages Specify a sender when sending a message: ```ruby theme={null} result = client.messages.send_( to: "+14155551234", text: "Hello!", zavu_sender: "snd_abc123" ) ``` If no sender is specified, the default sender is used. # Sub-Accounts Source: https://docs.zavu.dev/sdks/ruby/sub-accounts Create and manage isolated sub-accounts for customers via the Zavu Ruby SDK. Independent API keys, spend limits and routing per sub-account. Sub-accounts let you provision isolated messaging environments for your customers with independent API keys and spending controls. ## Create Sub-Account ```ruby theme={null} result = client.sub_accounts.create( name: "Client ABC", external_id: "client_123", credit_limit: 100000, # $1,000.00 in cents metadata: { "plan" => "enterprise", "region" => "us-east" } ) # Save this — only shown once puts result.sub_account.api_key puts result.sub_account.id puts result.sub_account.status # active ``` > [!WARNING] > > The `api_key` field is only included in the creation response. Store it securely. ## Get Sub-Account ```ruby theme={null} result = client.sub_accounts.retrieve(id: "jx7abc123def456") puts result.sub_account.name puts result.sub_account.status puts result.sub_account.total_spent puts result.sub_account.credit_limit ``` ## List Sub-Accounts ```ruby theme={null} result = client.sub_accounts.list(limit: 50) result.items.each do |sub| puts "#{sub.name}: $#{'%.2f' % (sub.total_spent / 100.0)} spent" end # Paginate next_page = client.sub_accounts.list( limit: 50, cursor: result.next_cursor ) ``` ## Update Sub-Account ```ruby theme={null} result = client.sub_accounts.update( "jx7abc123def456", credit_limit: 200000, # Increase to $2,000 metadata: { "plan" => "enterprise-plus" } ) puts result.sub_account.credit_limit # 200000 ``` ### Suspend a Sub-Account ```ruby theme={null} client.sub_accounts.update( "jx7abc123def456", status: "inactive" ) # Re-activate later client.sub_accounts.update( "jx7abc123def456", status: "active" ) ``` ## Get Sub-Account Balance ```ruby theme={null} balance = client.sub_accounts.get_balance("jx7abc123def456") puts "Team balance: $#{'%.2f' % (balance.balance / 100.0)}" puts "Sub-account spent: $#{'%.2f' % (balance.total_spent / 100.0)}" puts "Credit limit: $#{'%.2f' % (balance.credit_limit / 100.0)}" puts balance.is_sub_account # true ``` ## Deactivate Sub-Account ```ruby theme={null} result = client.sub_accounts.deactivate("jx7abc123def456") puts "Keys revoked: #{result.keys_revoked}" ``` ## API Keys ### List API Keys ```ruby theme={null} result = client.sub_accounts.api_keys.list("jx7abc123def456") result.items.each do |key| puts "#{key.name} (#{key.key_prefix}...): #{key.environment}" end ``` ### Create API Key ```ruby theme={null} result = client.sub_accounts.api_keys.create( "jx7abc123def456", name: "Production Key", environment: "live" ) # Save this — only shown once puts "New key: #{result.api_key.key}" ``` ### Revoke API Key ```ruby theme={null} client.sub_accounts.api_keys.revoke("jx7abc123def456", "key_id_here") ``` # Templates Source: https://docs.zavu.dev/sdks/ruby/templates Create, list and submit WhatsApp message templates via the Zavu Ruby SDK. Manage variables, categories and approval status from your code. Templates are reusable message formats for sending structured messages across WhatsApp, SMS, Telegram, and other channels. WhatsApp requires template approval for business-initiated conversations. ## Create Template ```ruby theme={null} result = client.templates.create( name: "order_confirmation", language: "en", body: "Hi {{1}}, your order #{{2}} is confirmed!", whatsapp_category: "UTILITY", variables: ["customer_name", "order_id"] ) puts result.id # tpl_xxx puts result.status # draft, pending, approved, rejected ``` ## List Templates ```ruby theme={null} result = client.templates.list result.items.each do |template| puts "#{template.id} #{template.name} #{template.status}" end # With pagination result = client.templates.list( limit: 50, cursor: "cursor_xxx" ) ``` ## Get Template ```ruby theme={null} result = client.templates.retrieve(template_id: "tpl_abc123") puts result.name puts result.body puts result.status ``` ## Delete Template ```ruby theme={null} client.templates.delete(template_id: "tpl_abc123") ``` ## Submit Template for Approval After creating a template, you need to submit it to Meta for approval before you can use it: ```ruby theme={null} result = client.templates.submit( template_id: "tpl_abc123", sender_id: "sender_xyz", category: "UTILITY" # Optional if already set on template ) puts result.status # "pending" ``` > [!NOTE] > > The `sender_id` must reference a sender that has a WhatsApp Business Account configured. Templates are submitted to the WABA associated with that sender. ## Template Categories | Category | Use Case | | - | - | | `UTILITY` | Order updates, account alerts, appointment reminders | | `MARKETING` | Promotions, offers, newsletters | | `AUTHENTICATION` | OTP codes, verification messages | ## Using Templates in Messages ```ruby theme={null} result = client.messages.send_( to: "+14155551234", message_type: "template", content: { "template_id" => "tpl_abc123", "template_variables" => { "1" => "John", "2" => "ORD-12345" } } ) ``` # Broadcasts Source: https://docs.zavu.dev/sdks/typescript/broadcasts Create, schedule and track bulk message broadcasts via the Zavu TypeScript SDK. Add contacts, send to thousands and monitor delivery progress. Broadcasts allow you to send messages to multiple recipients in a single campaign. ## Create Broadcast ```typescript theme={null} const result = await client.broadcasts.create({ name: "Black Friday Sale", channel: "sms", text: "Hi {{name}}, check out our Black Friday deals! Use code FRIDAY20 for 20% off.", }); console.log(result.broadcast.id); // brd_xxx console.log(result.broadcast.status); // draft ``` ### WhatsApp Template Broadcast ```typescript theme={null} const result = await client.broadcasts.create({ name: "Order Confirmation Campaign", channel: "whatsapp", messageType: "template", content: { templateId: "tpl_abc123", }, }); ``` ### Email Broadcast ```typescript theme={null} const result = await client.broadcasts.create({ name: "Newsletter", channel: "email", emailSubject: "Your Weekly Update", text: "Hi {{name}}, here's what's new this week...", }); ``` ## Get Broadcast ```typescript theme={null} const result = await client.broadcasts.retrieve({ broadcastId: "brd_abc123", }); console.log(result.broadcast.name); console.log(result.broadcast.status); console.log(result.broadcast.totalContacts); ``` ## List Broadcasts ```typescript theme={null} const result = await client.broadcasts.list({}); for (const broadcast of result.items) { console.log(broadcast.id, broadcast.name, broadcast.status); } // Filter by status const result = await client.broadcasts.list({ status: "completed", limit: 50, }); ``` ## Update Broadcast Update a broadcast in draft status: ```typescript theme={null} const result = await client.broadcasts.update({ broadcastId: "brd_abc123", name: "Updated Campaign Name", text: "New message content with {{name}}", }); ``` ## Add Contacts Add contacts to a broadcast (max 1000 per request): ```typescript theme={null} const result = await client.broadcasts.contacts.add({ broadcastId: "brd_abc123", contacts: [ { recipient: "+14155551234", templateVariables: { name: "John", order_id: "ORD-001", }, }, { recipient: "+14155555678", templateVariables: { name: "Jane", order_id: "ORD-002", }, }, ], }); console.log(result.added); // 2 console.log(result.duplicates); // 0 console.log(result.invalid); // 0 ``` ## List Broadcast Contacts ```typescript theme={null} const result = await client.broadcasts.contacts.list({ broadcastId: "brd_abc123", status: "pending", limit: 100, }); for (const contact of result.items) { console.log(contact.recipient, contact.status); } ``` ## Remove Contact ```typescript theme={null} await client.broadcasts.contacts.remove({ broadcastId: "brd_abc123", contactId: "bcon_xyz789", }); ``` ## Send Broadcast Start sending immediately: ```typescript theme={null} const result = await client.broadcasts.send({ broadcastId: "brd_abc123", }); console.log(result.broadcast.status); // sending ``` Schedule for later: ```typescript theme={null} const result = await client.broadcasts.send({ broadcastId: "brd_abc123", scheduledAt: "2024-01-15T10:00:00Z", }); console.log(result.broadcast.status); // scheduled ``` ## Reschedule Broadcast Change the scheduled time for a broadcast in `scheduled` status: ```typescript theme={null} const result = await client.broadcasts.reschedule({ broadcastId: "brd_abc123", scheduledAt: "2024-01-16T14:00:00Z", }); console.log(result.broadcast.scheduledAt); // 2024-01-16T14:00:00.000Z ``` > [!NOTE] > > You can only reschedule broadcasts that are in `scheduled` status. The new time must be in the future. ## Get Progress Monitor broadcast progress in real-time: ```typescript theme={null} const result = await client.broadcasts.progress({ broadcastId: "brd_abc123", }); console.log(result.status); // sending console.log(result.total); // 5000 console.log(result.delivered); // 2350 console.log(result.failed); // 50 console.log(result.percentComplete); // 48.0 console.log(result.estimatedCompletionAt); ``` ## Cancel Broadcast ```typescript theme={null} const result = await client.broadcasts.cancel({ broadcastId: "brd_abc123", }); console.log(result.broadcast.status); // cancelled ``` > [!WARNING] > > Cancelling a broadcast will skip pending contacts, but messages already queued may still be delivered. ## Delete Broadcast Delete a broadcast in draft status: ```typescript theme={null} await client.broadcasts.delete({ broadcastId: "brd_abc123", }); ``` # Voice Calls with the TypeScript SDK Source: https://docs.zavu.dev/sdks/typescript/calls Place outbound voice-agent calls, read transcripts, and hang up active calls via the Zavu TypeScript SDK. Voice calls are conversations handled by the AI agent configured on a sender. Placing a call dials the recipient and connects them to the agent — speech recognition, the agent's LLM, and speech synthesis run in Zavu's managed voice pipeline. The `calls` resource is available since `@zavudev/sdk` 0.56.0. ## Place a Call `to` is the only required field. The sender's agent must have `voice.enabled: true`. ```typescript theme={null} const { call } = await client.calls.create({ to: "+56912345678", }); console.log(call.id, call.status); // call_xxx "queued" ``` ### With Overrides `greeting`, `language`, and `maxDurationMinutes` override the agent's configuration for this call only. `metadata` is returned on the call object and included in voice webhooks. ```typescript theme={null} const { call } = await client.calls.create({ to: "+56912345678", senderId: "snd_abc123", greeting: "Hi, this is Acme calling about your appointment.", language: "es-ES", // or "auto" to follow the caller maxDurationMinutes: 10, metadata: { campaign: "appointment_reminders" }, }); ``` The call is returned immediately with status `queued` while it starts dialing. Track progress via the `call.initiated`, `call.answered`, `call.completed`, and `call.failed` webhook events, or by polling `calls.retrieve`. ## Get a Call and Its Transcript Fetching a single call includes the full transcript once the conversation has produced turns. Each turn is `{ seq, role, text }` where `role` is `user`, `assistant`, or `tool`. ```typescript theme={null} const { call } = await client.calls.retrieve("call_abc123"); console.log(call.status, call.durationSeconds, call.endReason, call.cost); for (const turn of call.transcript ?? []) { console.log(`${turn.role}: ${turn.text}`); } ``` `durationSeconds`, `endReason`, `turnCount`, and `cost` populate once the call ends. `cost` is the total in USD, combining the managed voice pipeline and telephony. ## List Calls Transcripts are omitted from list responses — fetch a single call to get one. The list auto-paginates: ```typescript theme={null} for await (const call of client.calls.list({ status: "completed", direction: "outbound", })) { console.log(call.id, call.to, call.durationSeconds); } ``` ## Hang Up a Call Ends an active call. The call must still be `queued`, `ringing` or `in_progress`; a call that already ended returns `409 conflict`. ```typescript theme={null} const { call } = await client.calls.hangup("call_abc123"); console.log(call.status); ``` ## Requirements and Errors * The sender's agent needs `voice.enabled: true`, or `create` fails with `400 "The sender's agent does not have voice enabled"`. * Placing and hanging up calls is not available with test-mode API keys; reads are. * Calls are billed per connected minute plus telephony from your prepaid balance. `402 insufficient_balance` means the balance cannot cover the call. ## Related * [Voice Agents overview](/guides/voice-agents/overview) — configuring the agent that answers * [Voice webhooks](/guides/voice-agents/webhooks) — `call.*` event payloads # Contacts Source: https://docs.zavu.dev/sdks/typescript/contacts Create, list, retrieve and update contacts via the Zavu TypeScript SDK. Manage profile metadata, channels and merge suggestions from your code. Contacts are automatically created when you send messages. You can retrieve and update contact information. ## Get Contact ```typescript theme={null} const result = await client.contacts.retrieve({ contactId: "con_abc123", }); console.log(result.id); console.log(result.phoneNumber); console.log(result.countryCode); console.log(result.availableChannels); console.log(result.defaultChannel); ``` ## Get Contact by Phone ```typescript theme={null} const result = await client.contacts.retrieveByPhone({ phoneNumber: "+14155551234", }); console.log(result.id); console.log(result.availableChannels); // ["sms", "whatsapp"] ``` ## Update Contact ```typescript theme={null} const result = await client.contacts.update({ contactId: "con_abc123", defaultChannel: "whatsapp", metadata: { name: "John Doe", tier: "premium", }, }); ``` ## List Contacts ```typescript theme={null} const result = await client.contacts.list({ limit: 50, }); for (const contact of result.items) { console.log(contact.id, contact.phoneNumber); } // Filter by phone number const result = await client.contacts.list({ phoneNumber: "+1415", }); // Pagination let cursor: string | undefined; do { const result = await client.contacts.list({ cursor, limit: 50 }); for (const contact of result.items) { console.log(contact.id); } cursor = result.nextCursor ?? undefined; } while (cursor); ``` ## Phone Introspection Validate a phone number and check available channels: ```typescript theme={null} const result = await client.introspect.validatePhone({ phoneNumber: "+14155551234", }); console.log(result.validNumber); // true console.log(result.countryCode); // "US" console.log(result.nationalFormat); // "(415) 555-1234" console.log(result.lineType); // "mobile" console.log(result.availableChannels); // ["sms", "whatsapp"] console.log(result.carrier?.name); // "Verizon Wireless" ``` # Invitations Source: https://docs.zavu.dev/sdks/typescript/invitations Generate partner invitations for client WhatsApp Business onboarding via the Zavu TypeScript SDK. Track status and manage expiration. Partner invitations allow you to generate links for clients to connect their WhatsApp Business accounts to your project. ## Create Invitation ```typescript theme={null} const invitation = await zavu.invitations.create({ clientName: "Acme Corp", clientEmail: "contact@acme.com", expiresInDays: 14, }); console.log(invitation.id); // jh7am5bng9p3v2x1k4r8 console.log(invitation.url); // https://dashboard.zavu.dev/invite/abc123xyz console.log(invitation.status); // pending ``` ### With Phone Number Restrictions Restrict which countries the client can register phone numbers from: ```typescript theme={null} const invitation = await zavu.invitations.create({ clientName: "Acme Corp", clientEmail: "contact@acme.com", expiresInDays: 7, allowedPhoneCountries: ["US", "MX"], }); ``` ## Get Invitation ```typescript theme={null} const { invitation } = await zavu.invitations.retrieve({ invitationId: "jh7am5bng9p3v2x1k4r8", }); console.log(invitation.status); // pending | in_progress | completed | expired | cancelled console.log(invitation.clientName); // Acme Corp console.log(invitation.senderId); // null (until completed) console.log(invitation.expiresAt); // 2025-01-15T00:00:00.000Z ``` ## List Invitations ```typescript theme={null} const { items, nextCursor } = await zavu.invitations.list({}); for (const invitation of items) { console.log(invitation.id, invitation.clientName, invitation.status); } ``` ### Filter by Status ```typescript theme={null} const { items } = await zavu.invitations.list({ status: "pending", limit: 50, }); ``` ### Pagination ```typescript theme={null} let cursor: string | undefined; do { const result = await zavu.invitations.list({ limit: 50, cursor, }); for (const invitation of result.items) { console.log(invitation.id); } cursor = result.nextCursor ?? undefined; } while (cursor); ``` ## Cancel Invitation Cancel an active invitation to prevent the client from using it: ```typescript theme={null} const { invitation } = await zavu.invitations.cancel({ invitationId: "jh7am5bng9p3v2x1k4r8", }); console.log(invitation.status); // cancelled ``` > [!NOTE] > > You cannot cancel a completed invitation. Once a sender is created, manage it through the senders API. ## After Completion When a client completes the signup flow, a sender is created in your project: ```typescript theme={null} const { invitation } = await zavu.invitations.retrieve({ invitationId: "jh7am5bng9p3v2x1k4r8", }); if (invitation.status === "completed" && invitation.senderId) { // Use the sender to send messages await zavu.messages.send({ to: "+14155551234", channel: "whatsapp", messageType: "template", content: { templateId: "tmpl_xyz789", templateVariables: { "1": "John", }, }, }, { headers: { "Zavu-Sender": invitation.senderId, }, }); } ``` ## Full Example ```typescript theme={null} import Zavudev from "@zavudev/sdk"; const zavu = new Zavudev({ apiKey: process.env["ZAVUDEV_API_KEY"], }); async function onboardClient(clientName: string, clientEmail: string) { // Create invitation const invitation = await zavu.invitations.create({ clientName, clientEmail, expiresInDays: 7, }); console.log(`Send this link to ${clientName}: ${invitation.url}`); return invitation; } async function checkInvitationStatus(invitationId: string) { const { invitation } = await zavu.invitations.retrieve({ invitationId }); switch (invitation.status) { case "pending": console.log("Waiting for client to start signup"); break; case "in_progress": console.log("Client is completing the signup flow"); break; case "completed": console.log(`Success! Sender ID: ${invitation.senderId}`); break; case "expired": console.log("Invitation expired, create a new one"); break; case "cancelled": console.log("Invitation was cancelled"); break; } return invitation; } // Usage const invitation = await onboardClient("Acme Corp", "contact@acme.com"); // Later... await checkInvitationStatus(invitation.id); ``` # Send Messages with the TypeScript SDK Source: https://docs.zavu.dev/sdks/typescript/messages Send and manage SMS, WhatsApp, Email, and Voice messages with the Zavu TypeScript SDK. ## Send a Message ```typescript theme={null} const result = await client.messages.send({ to: "+14155551234", text: "Your verification code is 123456", channel: "sms", // optional: sms, whatsapp, email metadata: { userId: "user_123", }, idempotencyKey: "unique-key", // optional: prevent duplicates }); console.log(result.message.id); // msg_xxx console.log(result.message.status); // queued ``` ## Send with Sender Specify which sender profile to use: ```typescript theme={null} const result = await client.messages.send({ to: "+14155551234", text: "Hello!", 'Zavu-Sender': "snd_abc123", }); ``` ## Send with Template Send a WhatsApp template message: ```typescript theme={null} const result = await client.messages.send({ to: "+14155551234", messageType: "template", content: { templateId: "tpl_abc123", templateVariables: { "1": "John", "2": "12345", }, }, }); ``` ## Send WhatsApp Media ### Image ```typescript theme={null} const result = await client.messages.send({ to: "+14155551234", messageType: "image", text: "Check out this product!", content: { mediaUrl: "https://example.com/image.jpg", }, }); ``` ### Document ```typescript theme={null} const result = await client.messages.send({ to: "+14155551234", messageType: "document", content: { mediaUrl: "https://example.com/invoice.pdf", filename: "invoice.pdf", }, }); ``` ### Video ```typescript theme={null} const result = await client.messages.send({ to: "+14155551234", messageType: "video", text: "Watch this!", content: { mediaUrl: "https://example.com/video.mp4", }, }); ``` ## Send Interactive Messages ### Buttons ```typescript theme={null} const result = await client.messages.send({ to: "+14155551234", messageType: "buttons", text: "How would you rate your experience?", content: { buttons: [ { id: "great", title: "Great!" }, { id: "okay", title: "It was okay" }, { id: "poor", title: "Not good" }, ], }, }); ``` ### List ```typescript theme={null} const result = await client.messages.send({ to: "+14155551234", messageType: "list", text: "Select an option:", content: { listButton: "View Options", sections: [ { title: "Products", rows: [ { id: "prod_1", title: "Product A", description: "$10.00" }, { id: "prod_2", title: "Product B", description: "$20.00" }, ], }, ], }, }); ``` ## Send Email ```typescript theme={null} const result = await client.messages.send({ to: "user@example.com", channel: "email", subject: "Your order has shipped", text: "Hi John, your order #12345 has shipped.", htmlBody: "

Order Shipped

Your order #12345 has shipped.

", replyTo: "support@example.com", }); ``` ## Send Email with Attachments ```typescript theme={null} import fs from "fs"; const result = await client.messages.send({ to: "user@example.com", channel: "email", subject: "Your invoice", text: "Please find your invoice attached.", attachments: [ { filename: "invoice.pdf", content: fs.readFileSync("invoice.pdf").toString("base64"), content_type: "application/pdf", }, ], }); ``` ### Attachment from URL ```typescript theme={null} const result = await client.messages.send({ to: "user@example.com", channel: "email", subject: "Your report", text: "Report attached.", attachments: [ { filename: "report.pdf", path: "https://your-server.com/reports/report.pdf", }, ], }); ``` ### Inline Image ```typescript theme={null} const result = await client.messages.send({ to: "user@example.com", channel: "email", subject: "Weekly report", text: "See your report.", htmlBody: '

Report

', attachments: [ { filename: "chart.png", path: "https://your-server.com/chart.png", content_type: "image/png", content_id: "chart", }, ], }); ``` ## Get Message Status ```typescript theme={null} const result = await client.messages.retrieve({ messageId: "msg_abc123", }); console.log(result.message.status); // queued, sending, sent, delivered, failed ``` ## List Messages ```typescript theme={null} // List all messages const result = await client.messages.list({}); // With filters const result = await client.messages.list({ status: "delivered", channel: "sms", limit: 100, }); // Pagination let cursor: string | undefined; do { const result = await client.messages.list({ cursor, limit: 50 }); for (const message of result.items) { console.log(message.id); } cursor = result.nextCursor ?? undefined; } while (cursor); ``` ## Send Reaction React to a WhatsApp message: ```typescript theme={null} const result = await client.messages.react({ messageId: "msg_abc123", emoji: "👍", }); ``` # TypeScript SDK Overview Source: https://docs.zavu.dev/sdks/typescript/overview Official Zavu SDK for Node.js and TypeScript. Send SMS, WhatsApp, Email and Voice with full type safety, retries and idempotency built in. > [!WARNING] > > **Server-side only** — This SDK is designed for server-side environments (Node.js, serverless functions). Never use it in browser/frontend code as this exposes your API key. See [Frontend Integration](/authentication#frontend-integration) for the proxy pattern. The official Zavu SDK for TypeScript provides a type-safe way to interact with the Zavu API. ## Installation ```bash theme={null} npm add @zavudev/sdk # or bun add @zavudev/sdk # or yarn add @zavudev/sdk ``` ## Requirements * Node.js 18 or higher * TypeScript 4.7+ (optional, for type definitions) ## Quick Start ```typescript theme={null} import Zavudev from '@zavudev/sdk'; const zavu = new Zavudev({ apiKey: process.env['ZAVUDEV_API_KEY'], }); const result = await zavu.messages.send({ to: "+14155551234", text: "Hello from Zavu!", }); console.log("Message sent:", result.message.id); ``` ## Configuration ### Initialize the Client ```typescript theme={null} import Zavudev from '@zavudev/sdk'; // Basic initialization const zavu = new Zavudev({ apiKey: process.env['ZAVUDEV_API_KEY'], }); // With custom base URL const zavu = new Zavudev({ apiKey: "zv_live_xxx", baseURL: "https://api.zavu.dev", }); ``` ### Environment Variables We recommend storing your API key in environment variables: ```typescript theme={null} const zavu = new Zavudev({ apiKey: process.env['ZAVUDEV_API_KEY'], }); ``` ## Accessing Response Headers Use `.withResponse()` to access the full HTTP response including headers (useful for rate limiting): ```typescript theme={null} const { response, result } = await zavu.messages.send.withResponse({ to: "+14155551234", text: "Hello!", }); const remaining = response.headers.get("X-RateLimit-Remaining"); console.log(`Remaining requests: ${remaining}`); console.log("Message ID:", result.message.id); ``` ## Error Handling ```typescript theme={null} import Zavudev, { ZavudevError, APIError } from '@zavudev/sdk'; const zavu = new Zavudev({ apiKey: process.env["ZAVUDEV_API_KEY"], }); try { await zavu.messages.send({ to: "invalid-number", text: "Hello", }); } catch (error) { if (error instanceof APIError) { console.error("Status:", error.status); console.error("Message:", error.message); } else if (error instanceof ZavudevError) { console.error("Error:", error.message); } else { throw error; } } ``` ### Error Types | Error Code | Description | | - | - | | `400` | Invalid request parameters | | `401` | Invalid or missing API key | | `403` | Insufficient permissions | | `404` | Resource not found | | `429` | Rate limit exceeded | ## Retries The SDK supports automatic retries with configurable backoff: ```typescript theme={null} const result = await zavu.messages.send( { to: "+14155551234", text: "Hello!", }, { retries: { strategy: "backoff", backoff: { initialInterval: 1, maxInterval: 50, exponent: 1.1, maxElapsedTime: 100, }, retryConnectionErrors: false, }, } ); ``` ## TypeScript Support The SDK includes full TypeScript definitions: ```typescript theme={null} import Zavudev from '@zavudev/sdk'; import type { MessageSendParams } from '@zavudev/sdk/resources/messages'; const zavu = new Zavudev({ apiKey: process.env["ZAVUDEV_API_KEY"], }); const request: MessageSendParams = { to: "+14155551234", text: "Hello!", }; const result = await zavu.messages.send(request); ``` ## CommonJS Support ```javascript theme={null} const Zavudev = require("@zavudev/sdk").default; const zavu = new Zavudev({ apiKey: process.env.ZAVUDEV_API_KEY, }); ``` # Phone Numbers Source: https://docs.zavu.dev/sdks/typescript/phone-numbers Search, purchase and assign phone numbers via the Zavu TypeScript SDK. Manage SMS, WhatsApp and Voice numbers across countries with one API. Phone numbers are required for sending SMS and WhatsApp messages. You can search for available numbers, purchase them, and manage them through the API. ## Search Available Numbers Find phone numbers available for purchase: ```typescript theme={null} const result = await client.phoneNumbers.searchAvailable({ countryCode: "US", type: "local", limit: 10, }); for (const number of result.items) { console.log(number.phoneNumber, number.locality, number.region); console.log("Monthly:", number.pricing.monthlyPrice); console.log("Free eligible:", number.pricing.isFreeEligible); } ``` ### Search Parameters ```typescript theme={null} const result = await client.phoneNumbers.searchAvailable({ countryCode: "US", // Required: Two-letter country code type: "local", // Optional: local, national, tollFree. The API also // takes mobile; this SDK does not type it yet, so use REST for it. contains: "555", // Optional: Pattern to search for limit: 20, // Optional: Max results (default: 10, max: 50) }); ``` ## Purchase a Phone Number ```typescript theme={null} const phoneNumber = await client.phoneNumbers.purchase({ phoneNumber: "+14155551234", name: "Customer Support", }); console.log(phoneNumber.phoneNumber.id); // pn_abc123 console.log(phoneNumber.phoneNumber.phoneNumber); // +14155551234 console.log(phoneNumber.phoneNumber.status); // active ``` > [!NOTE] > > Buying numbers requires a paid plan (`402 paid_plan_required` on Free). A paid plan includes one number at no charge, once per account: a US or Canadian number (a +1 number) costing \$20 a month or less; `isFreeEligible` in search results marks the number that qualifies. > [!NOTE] > > Some numbers require regulatory information before they can be used; the purchase checks the exact number before charging anything. The SDK does not send `type` or `regulatoryRequirements` yet: for those numbers use the REST flow in [Regulatory Requirements](/guides/phone-numbers/regulatory-requirements), then poll the number's regulatory status until it is `approved`. A sender can be assigned before or after approval. ## List Phone Numbers ```typescript theme={null} const result = await client.phoneNumbers.list({}); for (const number of result.items) { console.log(number.id, number.phoneNumber, number.name); console.log("Status:", number.status); console.log("Assigned to:", number.senderId); } // With filters const filtered = await client.phoneNumbers.list({ status: "active", limit: 50, cursor: "cursor_xxx", }); ``` ## Get Phone Number ```typescript theme={null} const { phoneNumber: number } = await client.phoneNumbers.retrieve("pn_abc123"); console.log(number.phoneNumber); console.log(number.name); console.log(number.capabilities); console.log(number.pricing.monthlyPrice); console.log(number.nextRenewalDate); ``` ## Update Phone Number Update the name or sender assignment: ```typescript theme={null} // Update name await client.phoneNumbers.update("pn_abc123", { name: "Marketing Line" }); // Assign to a sender await client.phoneNumbers.update("pn_abc123", { senderId: "snd_xyz789" }); // Unassign from sender await client.phoneNumbers.update("pn_abc123", { senderId: null }); ``` ## Release Phone Number Release a phone number you no longer need: ```typescript theme={null} await client.phoneNumbers.release("pn_abc123"); ``` > [!WARNING] > > You cannot release a phone number assigned to a sender. Unassign it first. ## Response Types ### Phone Number Object ```typescript theme={null} interface PhoneNumber { id: string; phoneNumber: string; name?: string; capabilities: string[]; // ["sms", "voice", "mms"] status: "active" | "suspended" | "pending"; // Not in the SDK types yet; present in the API response. regulatoryStatus: "approved" | "pending_review" | "rejected"; senderId?: string; pricing: { monthlyPrice: number; upfrontCost: number; monthlyCost: number; isFreeNumber: boolean; }; nextRenewalDate?: string; createdAt: string; updatedAt?: string; } ``` ### Available Phone Number Object ```typescript theme={null} interface AvailablePhoneNumber { phoneNumber: string; friendlyName?: string; locality?: string; region?: string; capabilities: { sms: boolean; voice: boolean; mms: boolean; }; pricing: { monthlyPrice: number; upfrontPrice: number; isFreeEligible: boolean; }; } ``` ## Error Handling ```typescript theme={null} import { APIError } from "@zavudev/sdk"; try { await client.phoneNumbers.purchase({ phoneNumber: "+14155551234" }); } catch (error) { if (error instanceof APIError) { const code = (error.error as { code?: string } | undefined)?.code; if (code === "insufficient_balance") { console.log("Add funds to your account"); } else if (code === "number_unavailable") { console.log("Phone number is no longer available"); } else if (code === "regulatory_compliance_required") { console.log("This number needs regulatory information"); } } } ``` # Manage Senders with the TypeScript SDK Source: https://docs.zavu.dev/sdks/typescript/senders Create, update and list sender profiles via the Zavu TypeScript SDK. Combine phone numbers, WhatsApp accounts and webhooks into routing identities. Senders are phone numbers or identities used to send messages. Each project can have multiple senders with one designated as default. ## List Senders ```typescript theme={null} const result = await client.senders.list({}); for (const sender of result.items) { console.log(sender.id, sender.name, sender.phoneNumber); if (sender.isDefault) { console.log("(Default sender)"); } } // With pagination const result = await client.senders.list({ limit: 50, cursor: "cursor_xxx", }); ``` ## Get Sender ```typescript theme={null} const result = await client.senders.retrieve({ senderId: "snd_abc123", }); console.log(result.id); console.log(result.name); console.log(result.phoneNumber); console.log(result.isDefault); ``` ## Create Sender ```typescript theme={null} const result = await client.senders.create({ name: "Marketing", phoneNumber: "+15551234567", setAsDefault: true, }); console.log(result.id); // snd_xxx ``` ### With Webhook ```typescript theme={null} const result = await client.senders.create({ name: "Marketing", phoneNumber: "+15551234567", webhookUrl: "https://your-app.com/webhooks", webhookEvents: ["message.inbound", "message.delivered", "message.failed"], }); // Webhook secret is only returned on create if (result.webhook?.secret) { console.log("Save this secret:", result.webhook.secret); } ``` ## Update Sender ```typescript theme={null} const result = await client.senders.update({ senderId: "snd_abc123", name: "Support", setAsDefault: true, }); ``` ### Update Webhook Configuration ```typescript theme={null} const result = await client.senders.update({ senderId: "snd_abc123", webhookUrl: "https://new-url.com/webhooks", webhookEvents: ["message.inbound"], webhookActive: true, }); ``` ### Disable Webhook ```typescript theme={null} const result = await client.senders.update({ senderId: "snd_abc123", webhookActive: false, }); ``` ### Remove Webhook ```typescript theme={null} const result = await client.senders.update({ senderId: "snd_abc123", webhookUrl: null, }); ``` ## Delete Sender ```typescript theme={null} await client.senders.delete({ senderId: "snd_abc123", }); ``` > [!NOTE] > > You cannot delete the default sender. Set another sender as default first. ## Regenerate Webhook Secret If your webhook secret is compromised, generate a new one: ```typescript theme={null} const result = await client.senders.regenerateWebhookSecret({ senderId: "snd_abc123", }); console.log("New secret:", result.secret); ``` > [!WARNING] > > After regenerating the secret, update your webhook handler to use the new secret. The old secret will no longer work. ## WhatsApp Business Profile Manage the WhatsApp Business Profile for senders with a connected WhatsApp Business Account. ### Get Profile ```typescript theme={null} const response = await client.senders.getProfile("snd_abc123"); console.log(response.profile.about); console.log(response.profile.description); console.log(response.profile.address); console.log(response.profile.email); console.log(response.profile.websites); console.log(response.profile.vertical); console.log(response.profile.profilePictureUrl); ``` ### Update Profile ```typescript theme={null} const response = await client.senders.updateProfile("snd_abc123", { about: "Your trusted online store", description: "Best products at competitive prices", address: "123 Main St, San Francisco, CA", email: "support@example.com", websites: ["https://example.com"], vertical: "RETAIL", }); ``` ### Upload Profile Picture ```typescript theme={null} const response = await client.senders.uploadProfilePicture("snd_abc123", { imageUrl: "https://example.com/logo.png", mimeType: "image/png", }); ``` > [!NOTE] > > Profile methods are only available for senders with a WhatsApp Business Account connected. ## Using Senders in Messages Specify a sender when sending a message: ```typescript theme={null} const result = await client.messages.send({ to: "+14155551234", text: "Hello!", 'Zavu-Sender': "snd_abc123", }); ``` If no sender is specified, the default sender is used. # Sub-Accounts Source: https://docs.zavu.dev/sdks/typescript/sub-accounts Create and manage isolated sub-accounts for customers via the Zavu TypeScript SDK. Independent API keys, spend limits and routing per sub-account. Sub-accounts let you provision isolated messaging environments for your customers with independent API keys and spending controls. ## Create Sub-Account ```typescript theme={null} const { subAccount } = await client.subAccounts.create({ name: "Client ABC", externalId: "client_123", creditLimit: 100000, // $1,000.00 in cents metadata: { plan: "enterprise", region: "us-east", }, }); // Save this — only shown once console.log(subAccount.apiKey); console.log(subAccount.id); console.log(subAccount.status); // active ``` > [!WARNING] > > The `apiKey` field is only included in the creation response. Store it securely. ## Get Sub-Account ```typescript theme={null} const { subAccount } = await client.subAccounts.retrieve({ id: "jx7abc123def456", }); console.log(subAccount.name); console.log(subAccount.status); console.log(subAccount.totalSpent); console.log(subAccount.creditLimit); ``` ## List Sub-Accounts ```typescript theme={null} const { items, nextCursor } = await client.subAccounts.list({ limit: 50, }); for (const sub of items) { console.log(`${sub.name}: $${(sub.totalSpent / 100).toFixed(2)} spent`); } // Paginate const nextPage = await client.subAccounts.list({ limit: 50, cursor: nextCursor, }); ``` ## Update Sub-Account ```typescript theme={null} const { subAccount } = await client.subAccounts.update("jx7abc123def456", { creditLimit: 200000, // Increase to $2,000 metadata: { plan: "enterprise-plus" }, }); console.log(subAccount.creditLimit); // 200000 ``` ### Suspend a Sub-Account ```typescript theme={null} const { subAccount } = await client.subAccounts.update("jx7abc123def456", { status: "inactive", }); // Re-activate later const { subAccount: reactivated } = await client.subAccounts.update("jx7abc123def456", { status: "active", }); ``` ## Get Sub-Account Balance ```typescript theme={null} const balance = await client.subAccounts.getBalance("jx7abc123def456"); console.log(`Team balance: $${(balance.balance / 100).toFixed(2)}`); console.log(`Sub-account spent: $${(balance.totalSpent / 100).toFixed(2)}`); console.log(`Credit limit: $${(balance.creditLimit / 100).toFixed(2)}`); console.log(balance.isSubAccount); // true ``` ## Deactivate Sub-Account ```typescript theme={null} const result = await client.subAccounts.deactivate("jx7abc123def456"); console.log(`Keys revoked: ${result.keysRevoked}`); ``` ## API Keys ### List API Keys ```typescript theme={null} const { items } = await client.subAccounts.apiKeys.list("jx7abc123def456"); for (const key of items) { console.log(`${key.name} (${key.keyPrefix}...): ${key.environment}`); } ``` ### Create API Key ```typescript theme={null} const { apiKey } = await client.subAccounts.apiKeys.create("jx7abc123def456", { name: "Production Key", environment: "live", }); // Save this — only shown once console.log(apiKey.key); ``` ### Revoke API Key ```typescript theme={null} // The key id comes first; the sub-account it belongs to is a param. await client.subAccounts.apiKeys.revoke("key_id_here", { id: "jx7abc123def456" }); ``` ## Check Balance (Sub-Account Perspective) When a sub-account uses its own API key, `GET /v1/balance` returns its spending data: ```typescript theme={null} // Using a sub-account API key const subClient = new Zavu({ apiKey: "zv_live_sub_xxxxxxxxxxxx" }); const balance = await subClient.balance.get(); console.log(balance.balance); // team balance console.log(balance.totalSpent); // sub-account spending console.log(balance.creditLimit); // spending cap console.log(balance.isSubAccount); // true ``` # Templates Source: https://docs.zavu.dev/sdks/typescript/templates Create, list and submit WhatsApp message templates via the Zavu TypeScript SDK. Manage variables, categories and approval status from your code. Templates are reusable message formats for sending structured messages across WhatsApp, SMS, Telegram, and other channels. WhatsApp requires template approval for business-initiated conversations. ## Create Template ```typescript theme={null} const result = await client.templates.create({ name: "order_confirmation", language: "en", body: "Hi {{1}}, your order #{{2}} is confirmed!", whatsappCategory: "UTILITY", variables: ["customer_name", "order_id"], }); console.log(result.id); // tpl_xxx console.log(result.status); // draft, pending, approved, rejected ``` ## List Templates ```typescript theme={null} const result = await client.templates.list({}); for (const template of result.items) { console.log(template.id, template.name, template.status); } // With pagination const result = await client.templates.list({ limit: 50, cursor: "cursor_xxx", }); ``` ## Get Template ```typescript theme={null} const result = await client.templates.retrieve({ templateId: "tpl_abc123", }); console.log(result.name); console.log(result.body); console.log(result.status); ``` ## Delete Template ```typescript theme={null} await client.templates.delete({ templateId: "tpl_abc123", }); ``` ## Submit Template for Approval After creating a template, you need to submit it to Meta for approval before you can use it: ```typescript theme={null} const result = await client.templates.submit({ templateId: "tpl_abc123", senderId: "sender_xyz", category: "UTILITY", // Optional if already set on template }); console.log(result.status); // "pending" ``` > [!NOTE] > > The `senderId` must reference a sender that has a WhatsApp Business Account configured. Templates are submitted to the WABA associated with that sender. ## Template Categories | Category | Use Case | | - | - | | `UTILITY` | Order updates, account alerts, appointment reminders | | `MARKETING` | Promotions, offers, newsletters | | `AUTHENTICATION` | OTP codes, verification messages | ## Using Templates in Messages Templates can be sent on any channel by specifying the `channel` parameter: ```typescript theme={null} // WhatsApp (default if no channel specified) const result = await client.messages.send({ to: "+14155551234", messageType: "template", content: { templateId: "tpl_abc123", templateVariables: { "1": "John", "2": "ORD-12345", }, }, }); // Telegram const telegramResult = await client.messages.send({ to: "123456789", channel: "telegram", messageType: "template", content: { templateId: "tpl_abc123", templateVariables: { "1": "John", "2": "ORD-12345", }, }, }); // SMS const smsResult = await client.messages.send({ to: "+14155551234", channel: "sms", messageType: "template", content: { templateId: "tpl_abc123", templateVariables: { "1": "John", "2": "ORD-12345", }, }, }); ``` > [!NOTE] > > When a channel-specific body (`smsBody`, `telegramBody`) is set on the template, it will be used instead of the default `body`. Contact variables like `{{contact.first_name}}` are auto-resolved from the recipient's contact metadata. # API v2 Source: https://docs.zavu.dev/sdks/typescript/v2 Call Zavu API v2 from the TypeScript SDK as client.v2: send from an address, connect channels, and read flat errors, with the same client and API key as v1. `@zavudev/sdk` 0.59.0 and later calls [API v2](/api-v2/overview) as `client.v2`, on the same client, API key, retries and pagination as v1. v1 stays where it was: `client.messages` is v1, `client.v2.messages` is v2, and the two can be mixed in one codebase. ```bash theme={null} npm add @zavudev/sdk@^0.59.0 ``` ```typescript theme={null} import Zavudev from '@zavudev/sdk'; const zavu = new Zavudev(); // reads ZAVUDEV_API_KEY const { message, routing } = await zavu.v2.messages.send({ from: '+13125551212', to: '+56912345678', text: 'Your code is 123456.', }); ``` ## Ids are positional Every id in a v2 path is an argument, in the order the path has it. Body and query fields go in the object after them. ```typescript theme={null} await zavu.v2.channels.activate('+13125551212', 'whatsapp'); await zavu.v2.channels.disconnect('@acme_bot', 'telegram'); await zavu.v2.agents.channels.disconnect('agent_abc123', '+13125551212', 'sms'); await zavu.v2.templates.submit('tmpl_abc123', { from: '+13125551212' }); ``` Pass an address as you read it from `GET /v2/channels`. The SDK puts it in the path for you; you do not encode the `+` yourself. ## Types v2's `Message`, `Contact` and `Template` are not v1's, so v2's types have their own entry point: ```typescript theme={null} import type { MessageSendParams, ChannelEntry } from '@zavudev/sdk/v2'; ``` The root of `@zavudev/sdk` keeps exporting v1's types, as before. ## Lists A list call pages for you. Iterate it to follow `nextCursor` to the end, or read one page: ```typescript theme={null} for await (const entry of zavu.v2.channels.list()) { console.log(entry.address, entry.type, entry.active); } const page = await zavu.v2.conversations.list({ limit: 20 }); ``` ## Errors A failed call throws the same classes as v1, from the root of the package. A v2 error body is `{code, message, details}` at the root, and the SDK keeps it on `error.error`: ```typescript theme={null} try { await zavu.v2.messages.send({ to: '+56912345678', text: 'Hi' }); } catch (err) { if (!(err instanceof Zavudev.ConflictError)) throw err; const body = err.error as { code?: string; details?: { connect?: unknown[] } } | undefined; if (body?.code !== 'no_channel') throw err; console.log(body.details?.connect); // the channels you could connect to send this } ``` A `409` is a `ConflictError` and is not retried: v2 answers it with `x-should-retry: false`, and the SDK reads that header. See [Errors](/api-v2/errors). ## Methods | Method | Endpoint | | - | - | | `client.v2.messages.send(body)` | `POST /v2/messages` | | `client.v2.messages.list(query)` | `GET /v2/messages` | | `client.v2.messages.retrieve(messageID)` | `GET /v2/messages/{messageId}` | | `client.v2.conversations.list(query)` | `GET /v2/conversations` | | `client.v2.conversations.retrieve(conversationID)` | `GET /v2/conversations/{conversationId}` | | `client.v2.conversations.listMessages(conversationID, query)` | `GET /v2/conversations/{conversationId}/messages` | | `client.v2.conversations.markAsRead(conversationID)` | `POST /v2/conversations/{conversationId}/read` | | `client.v2.contacts.list(query)` | `GET /v2/contacts` | | `client.v2.contacts.create(body)` | `POST /v2/contacts` | | `client.v2.contacts.retrieve(contactID)` | `GET /v2/contacts/{contactId}` | | `client.v2.contacts.update(contactID, body)` | `PATCH /v2/contacts/{contactId}` | | `client.v2.contacts.delete(contactID)` | `DELETE /v2/contacts/{contactId}` | | `client.v2.channels.list(query)` | `GET /v2/channels` | | `client.v2.channels.connect(body)` | `POST /v2/channels` | | `client.v2.channels.retrieve(address)` | `GET /v2/channels/{address}` | | `client.v2.channels.activate(address, type)` | `POST /v2/channels/{address}/{type}/activate` | | `client.v2.channels.deactivate(address, type)` | `POST /v2/channels/{address}/{type}/deactivate` | | `client.v2.channels.disconnect(address, type)` | `DELETE /v2/channels/{address}/{type}` | | `client.v2.templates.list(query)` | `GET /v2/templates` | | `client.v2.templates.create(body)` | `POST /v2/templates` | | `client.v2.templates.retrieve(templateID)` | `GET /v2/templates/{templateId}` | | `client.v2.templates.delete(templateID)` | `DELETE /v2/templates/{templateId}` | | `client.v2.templates.submit(templateID, body)` | `POST /v2/templates/{templateId}/submit` | | `client.v2.campaigns.list(query)` | `GET /v2/campaigns` | | `client.v2.campaigns.create(body)` | `POST /v2/campaigns` | | `client.v2.campaigns.retrieve(campaignID)` | `GET /v2/campaigns/{campaignId}` | | `client.v2.campaigns.delete(campaignID)` | `DELETE /v2/campaigns/{campaignId}` | | `client.v2.campaigns.recipients.add(campaignID, body)` | `POST /v2/campaigns/{campaignId}/recipients` | | `client.v2.campaigns.send(campaignID, body)` | `POST /v2/campaigns/{campaignId}/send` | | `client.v2.campaigns.progress(campaignID)` | `GET /v2/campaigns/{campaignId}/progress` | | `client.v2.campaigns.cancel(campaignID)` | `POST /v2/campaigns/{campaignId}/cancel` | | `client.v2.webhook.retrieve()` | `GET /v2/webhook` | | `client.v2.webhook.set(body)` | `PUT /v2/webhook` | | `client.v2.webhook.delete()` | `DELETE /v2/webhook` | | `client.v2.webhook.rotateSecret()` | `POST /v2/webhook/secret` | | `client.v2.agents.list(query)` | `GET /v2/agents` | | `client.v2.agents.create(body)` | `POST /v2/agents` | | `client.v2.agents.retrieve(agentID)` | `GET /v2/agents/{agentId}` | | `client.v2.agents.update(agentID, body)` | `PATCH /v2/agents/{agentId}` | | `client.v2.agents.delete(agentID)` | `DELETE /v2/agents/{agentId}` | | `client.v2.agents.test(agentID, body)` | `POST /v2/agents/{agentId}/test` | | `client.v2.agents.channels.connect(agentID, body)` | `POST /v2/agents/{agentId}/channels` | | `client.v2.agents.channels.disconnect(agentID, address, type)` | `DELETE /v2/agents/{agentId}/channels/{address}/{type}` | | `client.v2.numbers.searchAvailable(query)` | `GET /v2/numbers/available` | | `client.v2.numbers.purchase(body)` | `POST /v2/numbers` | | `client.v2.emailDomains.list()` | `GET /v2/email-domains` | | `client.v2.emailDomains.create(body)` | `POST /v2/email-domains` | | `client.v2.emailDomains.retrieve(emailDomainID)` | `GET /v2/email-domains/{emailDomainId}` | | `client.v2.emailDomains.delete(emailDomainID)` | `DELETE /v2/email-domains/{emailDomainId}` | | `client.v2.emailDomains.verify(emailDomainID)` | `POST /v2/email-domains/{emailDomainId}/verify` | | `client.v2.functions.list(query)` | `GET /v2/functions` | | `client.v2.functions.retrieve(functionID)` | `GET /v2/functions/{functionId}` | | `client.v2.functions.update(functionID, body)` | `PATCH /v2/functions/{functionId}` | What v2 does not cover (voice calls, 10DLC, memory, sub-accounts and the rest listed in [Migrate from v1](/api-v2/migrate-from-v1)) stays on the v1 methods of the same client. # Coding Agent Skills Source: https://docs.zavu.dev/tools/coding-agent-skills Give your AI coding agent intelligent context about the Zavu API with pre-built skills — works with 40+ agents Coding Agent Skills provide intelligent, contextual knowledge about the Zavu API directly inside your favorite AI coding agent. Instead of searching docs or copying code snippets, your agent automatically understands channels, message types, webhook patterns, and API conventions — and writes correct code on the first try. > [!NOTE] > > Skills provide **context and decision-making intelligence**. For direct API execution, use the [MCP Server](/tools/mcp-server). They work great together — skills tell your agent *what to do*, MCP lets it *do it*. ## Supported Agents Skills work with **40+ AI coding agents**. The following are always included: > [card] Amp > [card] Cline > [card] Codex > [card] Cursor > [card] Gemini CLI > [card] GitHub Copilot > [card] Claude Code > [card] Kimi Code CLI > [card] OpenCode > [card] Warp Plus 30+ additional agents including Antigravity, Augment, CodeBuddy, Command Code, Continue, Cortex Code, and more. During installation you can select which agents to install to. ## Install ```bash theme={null} npx skills add zavudev/zavu-skills ``` The installer will prompt you to select which agents you want to install the skills to. Your agent will automatically load relevant skills based on what you're building. ## Available Skills Twelve skills ship in the repo. The agent-building ones come first, because they are the ones that write code. > [card] Functions > > `defineAgent` and `defineTool`, the deploy loop, triggers, secrets, and how to debug an execution > [card] Voice Agent > > Agents that answer and place calls — voice config, transcripts, tools, human handoff > [card] AI Agent > > The imperative path: configure an agent over the REST API for no-code and dashboard setups > [card] Eve Compat > > Deploy an eve-layout project onto Zavu, or import it straight from GitHub > [card] Send Message > > Channel selection decision tree, every message type (SMS, WhatsApp, Email, Telegram, Instagram, Messenger, Voice), error patterns > [card] Channel Setup > > How accounts, senders and channels relate — what you connect, what bills, and which sender to send from > [card] Webhook Setup > > Webhook configuration, signature verification code (TypeScript + Python), retry policy > [card] WhatsApp Templates > > Template creation, Meta approval workflow, OTP authentication, button types > [card] Broadcast Campaign > > Full broadcast lifecycle — create, add contacts, send, monitor progress, handle review > [card] Contacts Management > > Multi-channel contacts, channel operations, merge suggestions, phone introspection, email validation > [card] Phone Numbers > > Search available numbers, purchase, regulatory requirements, sender assignment > [card] Zavu Rules > > Always-loaded ambient context — SDK ecosystem, auth, conventions, business rules ## How It Works Skills are markdown files that your coding agent loads automatically based on your task. When you ask it to "send a WhatsApp message" or "set up webhooks", it already knows: * Which channel to use based on message type and recipient * The correct SDK patterns for TypeScript and Python * Business rules like the WhatsApp 24h window and what a new account can send * Error codes and how to handle them * API constraints (button limits, pagination patterns, etc.) ### Example: Channel Selection When you ask your agent to send a message, the `send-message` skill gives it this decision tree: ``` Is recipient an email address? → channel = "email" Is message type non-text (image, video, buttons, etc.)? → channel = "whatsapp" (auto-selected) Need voice call / TTS? → channel = "voice" Want Zavu to pick the channel? → channel = "auto" (first channel the sender has configured that the recipient's address can receive) Default? → channel = "sms" ``` Your agent uses this logic to pick the right channel without you specifying it. ## Skills + MCP Server For the best experience, install both skills and the MCP server: ```bash theme={null} # Skills: context and intelligence (works with 40+ agents) npx skills add zavudev/zavu-skills # MCP Server: direct API execution claude mcp add zavudev --scope user \ -e ZAVUDEV_API_KEY="your_key" -- npx -y @zavudev/sdk-mcp ``` With both installed, your coding agent can: 1. **Decide** the right approach using skills (channel selection, template vs. free-form, etc.) 2. **Execute** API calls directly using the MCP server 3. **Write** production-ready code using correct SDK patterns ## What You Can Build Once skills are installed, try asking your agent: ```text Notifications theme={null} "Build a Next.js API route that sends order confirmation via WhatsApp template, with SMS fallback for users outside the 24h window" ``` ```text Webhooks theme={null} "Create an Express server that receives Zavu webhooks, verifies signatures, and stores inbound messages in Postgres" ``` ```text Broadcasts theme={null} "Build a campaign manager that reads contacts from a CSV, creates a broadcast, and monitors delivery progress" ``` ```text AI Chatbot theme={null} "Set up an AI agent with a product FAQ knowledge base that responds to WhatsApp messages and can look up order status via webhook" ``` ```text Full Setup theme={null} "Set up my Zavu project: buy a US phone number, create a sender, configure webhooks for inbound and delivery events, and create an order confirmation WhatsApp template" ``` ## Skill Details ### Always-Loaded: Zavu Rules The `zavu-rules` skill loads automatically in every conversation. It provides: * **SDK init patterns** for TypeScript, Python, Go, PHP, and Ruby * **Authentication** conventions (`ZAVUDEV_API_KEY`, key prefixes `zv_live_`/`zv_test_`) * **E.164 phone format** and channel enum values * **Cursor-based pagination** pattern * **Error handling** with `APIError` * **Key business rules** (WhatsApp 24h window, sending limits for new accounts, URL verification) ### On-Demand Skills Other skills load when your agent detects they're relevant to your task: | Skill | Triggers When You... | | - | - | | `functions` | Build Zavu Functions: `defineAgent`, `defineTool`, `npx zavudev deploy`, debug executions | | `voice-agent` | Build an agent that answers or places phone calls | | `ai-agent` | Configure AI agents through the imperative API (no-code / dashboard setups) | | `eve-compat` | Have an eve-layout project and want it answering on Zavu's channels | | `send-message` | Build code to send messages via any channel | | `channel-setup` | Wire up a sender, connect a channel, or figure out which sender to send from | | `webhook-setup` | Set up webhook endpoints or signature verification | | `whatsapp-templates` | Create or manage WhatsApp templates | | `broadcast-campaign` | Work with bulk messaging or campaigns | | `contacts-management` | Manage contacts, channels, or merge logic | | `phone-numbers` | Search, purchase, or manage phone numbers | ## Requirements * Any supported AI coding agent (Cursor, Claude Code, Copilot, Cline, etc.) * Node.js 18+ (for `npx skills add`) * Optionally: `ZAVUDEV_API_KEY` environment variable (for MCP server) ## Source Code The skills are open source. Contributions welcome: > [card] [GitHub Repository](https://github.com/zavudev/zavu-skills) > > View source, report issues, or contribute new skills > [card] [MCP Server](/tools/mcp-server) > > Add direct API execution to your coding agent # MCP Server Source: https://docs.zavu.dev/tools/mcp-server Integrate the Zavu messaging API directly into AI assistants using Model Context Protocol (MCP). The Zavu MCP Server enables AI assistants like Claude, Cursor, and others to interact directly with the Zavu API. Send messages, manage templates, create broadcasts, and more through natural language. > [!NOTE] > > We're updating the MCP server. In the meantime, [Coding Agent Skills](/tools/coding-agent-skills) give your agent what it needs to use the Zavu API. If you already have Deno 2.6 or earlier installed, you can run the server with code execution on your own machine by adding `--code-execution-mode=local --docs-search-mode=local` to the arguments below. ## Quick Start Run the MCP Server directly via npx: ```bash theme={null} export ZAVUDEV_API_KEY="your_key" npx -y @zavudev/sdk-mcp@latest ``` Every client below runs this same command. Only the place you put it, and the shape of that file, changes. Replace `your_key` with an API key from the dashboard. ## Installation by Client ### Claude Code ```bash theme={null} claude mcp add zavudev --scope user -e ZAVUDEV_API_KEY="your_key" -- npx -y @zavudev/sdk-mcp ``` ### Codex ```bash theme={null} codex mcp add zavudev --env ZAVUDEV_API_KEY="your_key" -- npx -y @zavudev/sdk-mcp ``` ### Claude Desktop Add to your Claude Desktop configuration file, keeping any servers already there: ```json macOS theme={null} // ~/Library/Application Support/Claude/claude_desktop_config.json { "mcpServers": { "zavudev": { "command": "npx", "args": ["-y", "@zavudev/sdk-mcp"], "env": { "ZAVUDEV_API_KEY": "your_key" } } } } ``` ```json Windows theme={null} // %APPDATA%\Claude\claude_desktop_config.json { "mcpServers": { "zavudev": { "command": "npx", "args": ["-y", "@zavudev/sdk-mcp"], "env": { "ZAVUDEV_API_KEY": "your_key" } } } } ``` ### Cursor Click the button below to install the MCP server in Cursor. It adds the server with `YOUR_API_KEY` as the key: replace it in Cursor's `mcp.json` (Settings > Tools & MCP). [![Add to Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en-US/install-mcp?name=zavudev\&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkB6YXZ1ZGV2L3Nkay1tY3AiXSwiZW52Ijp7IlpBVlVERVZfQVBJX0tFWSI6IllPVVJfQVBJX0tFWSJ9fQ) Or add it to `~/.cursor/mcp.json` yourself: ```json theme={null} { "mcpServers": { "zavudev": { "command": "npx", "args": ["-y", "@zavudev/sdk-mcp"], "env": { "ZAVUDEV_API_KEY": "your_key" } } } } ``` Windsurf reads the same shape from `~/.codeium/windsurf/mcp_config.json`. ### VS Code [Install in VS Code](https://vscode.stainless.com/mcp/%7B%22name%22%3A%22zavudev%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40zavudev%2Fsdk-mcp%22%5D%2C%22env%22%3A%7B%22ZAVUDEV_API_KEY%22%3A%22YOUR_API_KEY%22%7D%7D), then replace `YOUR_API_KEY` via Command Palette > MCP: Open User Configuration. Or add it to `.vscode/mcp.json`. VS Code reads `servers`, not `mcpServers`: ```json theme={null} { "servers": { "zavudev": { "type": "stdio", "command": "npx", "args": ["-y", "@zavudev/sdk-mcp"], "env": { "ZAVUDEV_API_KEY": "your_key" } } } } ``` ### Zed Add to Zed's `settings.json`. Zed reads `context_servers`: ```json theme={null} { "context_servers": { "zavudev": { "source": "custom", "command": "npx", "args": ["-y", "@zavudev/sdk-mcp"], "env": { "ZAVUDEV_API_KEY": "your_key" } } } } ``` ## Tools The server exposes two tools rather than one per endpoint, which keeps the client's context small while covering the whole API: | Tool | Description | | - | - | | `execute` | Runs TypeScript against an authenticated Zavu client. The agent chains calls: list your senders, send a message, read a conversation. | | `search_docs` | Searches the API documentation, so the agent looks up a parameter instead of guessing it. | To start the server with only one of them, pass `--tools=code` or `--tools=docs`. Those are the only accepted values: any other value makes the server print its help and exit, and the client then shows it as failed. By default, code run by `execute` runs in a hosted sandbox. Pass `--code-execution-mode=local` to run it on your machine instead. ## Hosted server A hosted endpoint is available as an alternative to the local process. It takes the key in the `x-zavudev-api-key` header: ```bash theme={null} claude mcp add zavudev --scope user --transport http https://zavudev.stlmcp.com --header "x-zavudev-api-key: your_key" ``` You can also serve it yourself over HTTP with `npx -y @zavudev/sdk-mcp --transport=http --port=3000`. ## Usage Examples Once configured, ask your AI assistant: * "List my senders and which channels each one can send on" * "List my WhatsApp templates" * "Create a broadcast campaign for Black Friday" * "Check the progress of my current broadcast" * "Search for available US phone numbers" ## Learn More > [card] [Coding Agent Skills](/tools/coding-agent-skills) > > Add intelligent API context to your coding agent with pre-built skills > [card] [Model Context Protocol](https://modelcontextprotocol.io/) > > Learn about the MCP standard > [card] [API Reference](/api-reference) > > Explore the full Zavu API # Number Lookup Source: https://docs.zavu.dev/tools/number-lookup Validate phone numbers and get carrier information before sending messages Number Lookup allows you to validate phone numbers and retrieve carrier information before sending messages. This helps you: * **Validate numbers** before adding them to your contact list * **Identify line types** (mobile, landline, VoIP) for better channel selection * **Get carrier information** for cost estimation and fraud prevention * **Determine available channels** (SMS, WhatsApp) for each number ## API Call Use the `/v1/introspect/phone` endpoint to look up phone number information. ### Request ```bash theme={null} curl -X POST https://api.zavu.dev/v1/introspect/phone \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "phoneNumber": "+14155551234" }' ``` ### Response ```json theme={null} { "phoneNumber": "+14155551234", "validNumber": true, "countryCode": "US", "nationalFormat": "(415) 555-1234", "lineType": "mobile", "carrier": { "name": "Verizon Wireless", "type": "mobile" }, "availableChannels": ["sms", "whatsapp"] } ``` ### Response Fields | Field | Type | Description | | - | - | - | | `phoneNumber` | string | The queried phone number in E.164 format | | `validNumber` | boolean | Whether the number is valid | | `countryCode` | string | ISO 3166-1 alpha-2 country code | | `nationalFormat` | string | Number formatted for the local country | | `lineType` | string | Type of line: `mobile`, `landline`, `voip`, `toll_free`, `unknown` | | `carrier` | object | Carrier information (name and type) | | `availableChannels` | array | Channels available for messaging: `sms`, `whatsapp` | ## SDK Usage ### TypeScript ```typescript theme={null} import Zavudev from '@zavudev/sdk'; const zavu = new Zavudev({ apiKey: process.env['ZAVUDEV_API_KEY'], // This is the default and can be omitted }); const result = await zavu.introspect.validatePhone({ phoneNumber: "+14155551234", }); if (result.validNumber) { console.log("Valid number from:", result.countryCode); console.log("Line type:", result.lineType); console.log("Carrier:", result.carrier?.name); console.log("Available channels:", result.availableChannels.join(", ")); } else { console.log("Invalid phone number"); } ``` ### Python ```python theme={null} import os from zavudev import Zavudev zavu = Zavudev( api_key=os.environ.get("ZAVUDEV_API_KEY"), # This is the default and can be omitted ) result = zavu.introspect.validate_phone(phone_number="+14155551234") if result.valid_number: print(f"Valid number from: {result.country_code}") print(f"Line type: {result.line_type}") print(f"Carrier: {result.carrier.name if result.carrier else 'Unknown'}") print(f"Available channels: {', '.join(result.available_channels)}") else: print("Invalid phone number") ``` ## Line Types The `lineType` field indicates the type of phone line: | Type | Description | | - | - | | `mobile` | Mobile/cellular phone | | `landline` | Fixed landline phone | | `voip` | Voice over IP service (Google Voice, Skype, etc.) | | `toll_free` | Toll-free number (1-800, etc.) | | `unknown` | Unable to determine | ## Available Channels The `availableChannels` array is determined dynamically based on the line type: | Line Type | SMS | WhatsApp | Notes | | - | - | - | - | | `mobile` | Yes | Yes | Full messaging support | | `voip` | Yes | No | VoIP can receive SMS but WhatsApp requires a real mobile number | | `toll_free` | Yes | No | Toll-free numbers support SMS only | | `landline` | No | No | Landlines cannot receive text messages | | `unknown` | Yes | No | Conservative: SMS is more universal | > [!TIP] > > Use the `availableChannels` array to determine which messaging channels you can use for each number. If a channel is not in the array, messages to that channel will fail. ```typescript theme={null} const result = await zavu.introspect.validatePhone({ phoneNumber: "+14155551234", }); // Check available channels before sending if (result.availableChannels.includes("whatsapp")) { // WhatsApp is available - this is a mobile number await zavu.messages.send({ to: "+14155551234", channel: "whatsapp", text: "Hello via WhatsApp!", }); } else if (result.availableChannels.includes("sms")) { // Fall back to SMS await zavu.messages.send({ to: "+14155551234", channel: "sms", text: "Hello via SMS!", }); } else { // No messaging channels available (landline) console.error("This number cannot receive messages"); } ``` > [!WARNING] > > If `availableChannels` is empty, the number cannot receive any messages. This typically happens with landline numbers or invalid numbers. ## Use Cases ### Pre-send Validation Validate numbers before sending messages to reduce failed deliveries: ```typescript theme={null} const result = await zavu.introspect.validatePhone({ phoneNumber: customerPhone, }); if (!result.validNumber) { throw new Error("Invalid phone number provided"); } // Safe to send message await zavu.messages.send({ to: customerPhone, text: "Your order has shipped!", }); ``` ### Channel Selection Choose the best channel based on line type: ```typescript theme={null} const result = await zavu.introspect.validatePhone({ phoneNumber: customerPhone, }); // Use WhatsApp for mobile, SMS for others const channel = result.lineType === "mobile" && result.availableChannels.includes("whatsapp") ? "whatsapp" : "sms"; await zavu.messages.send({ to: customerPhone, channel: channel, text: "Hello!", }); ``` ### Fraud Prevention Identify potentially fraudulent numbers by checking line type: ```typescript theme={null} const result = await zavu.introspect.validatePhone({ phoneNumber: userPhone, }); // Flag VoIP numbers for additional verification if (result.lineType === "voip") { console.warn("VoIP number detected - consider additional verification"); } ``` ## Pricing Number lookup requests are billed separately from messaging. See our [pricing page](https://zavu.dev/pricing) for current rates. > [!NOTE] > > Lookup results are cached for 24 hours. Subsequent lookups for the same number within this period will use the cached result. ## Error Handling ### Invalid Phone Number Format ```json theme={null} { "code": "bad_request", "message": "Invalid phone number format. Use E.164 format (e.g., +14155551234)" } ``` ### Rate Limit Exceeded ```json theme={null} { "code": "rate_limit_exceeded", "message": "Too many requests. Please try again later." } ``` > [!WARNING] > > Always include proper error handling in your integration to gracefully handle API errors.