# 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. # 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. 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. A team that already paid this fee through the compliance flow is not charged again. 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 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 an AI agent. # 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. Writes nothing and charges nothing, so it is safe to call repeatedly while iterating on a prompt. Note that a dry run never **executes** tools — running them would cause real side effects. Live conversations on every channel do call them. When the agent has enabled tools, that gap is reported in `warnings` rather than silently producing an answer that looks like a tool call happened. # 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. # 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. # 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. # 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 and conversation threads are removed. Past message records and broadcast delivery logs are retained for billing/audit but no longer reference the deleted contact. # Delete sender Source: https://docs.zavu.dev/api-reference/delete-sender /openapi.json delete /v1/senders/{senderId} # Delete template Source: https://docs.zavu.dev/api-reference/delete-template /openapi.json delete /v1/templates/{templateId} # Dismiss merge suggestion Source: https://docs.zavu.dev/api-reference/dismiss-merge-suggestion /openapi.json delete /v1/contacts/{contactId}/merge-suggestion Dismiss the merge suggestion for a contact. # 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 regulatory requirements Source: https://docs.zavu.dev/api-reference/get-regulatory-requirements /openapi.json get /v1/phone-numbers/requirements Get regulatory requirements for purchasing phone numbers in a specific country. Some countries require additional documentation (addresses, identity documents) before phone numbers can be activated. # 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. # 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`. Paid plans include the first US number at no charge. # 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 for phone number purchases. Some countries require a verified address before phone numbers can be activated. # 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. Cannot delete addresses that are in use. # 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. # 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. Over it, sends return 429 with code `a2p_limit_exceeded` and upgrade details; 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 and 100/day. Teams on earlier plans keep their original email quotas instead - SMS and voice are billed per message from your balance on every plan **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. **Verification is required to send, and there are two of them.** The team must have completed both identity verification (KYC) and business verification (KYB); passing one is not enough. Drafts can be created, edited and kept without either. Every send path — dashboard, API and CLI alike — enforces both, returning `403` with code `kyc_required` or `kyb_required` for whichever is outstanding. **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 automated review passes. Every other channel moves to `pending_admin_review` and waits for a person. If the review rejects it, use PATCH to edit the content then call POST /retry-review. 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. # 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. # 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. # 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 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:** All SMS and Email messages containing URLs require those URLs to be verified before the message can be sent. 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 ringing or in progress. 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:** - The Voice Agents feature must be enabled for your team (otherwise `403`). - 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. # 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 | Testing without sending real messages | Test keys (`zv_test_`) simulate message sending but don't actually deliver messages. Use them for development and testing. ## Creating API Keys 1. Log in to your [Zavu Dashboard](https://dashboard.zavu.dev) 2. Navigate to **Settings** → **API Keys** 3. Click **Create API Key** 4. Give it a descriptive name (e.g., "Production Server", "Development") 5. Copy and securely store the key API keys are only shown once at creation. If you lose a key, you'll need to create a new one. ## 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} ## Optional Headers | Header | Description | Example | | ----------------- | --------------------------- | -------------------------- | | `Zavu-Sender` | Override the default sender | `snd_abc123` | | `Idempotency-Key` | Prevent duplicate sends | `order-12345-confirmation` | ### Zavu-Sender Header Override the default sender for a specific request: ```bash theme={null} curl -X POST https://api.zavu.dev/v1/messages \ -H "Authorization: Bearer zv_live_xxx" \ -H "Zavu-Sender: snd_abc123" \ -H "Content-Type: application/json" \ -d '{"to": "+56912345678", "text": "Hello!"}' ``` ### Idempotency Keys Prevent duplicate message sends due to network retries: ```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", "text": "Your order #12345 has shipped!", "idempotencyKey": "order-12345-shipped" }' ``` If you retry this request with the same `idempotencyKey`, you'll receive a `409 Conflict` with the original message instead of sending a duplicate. ## Security Best Practices 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(); } ``` Add your own authentication and validation to the proxy endpoint. The examples above are simplified for clarity. ## Key Permissions API keys can be scoped to specific permissions: | Permission | Description | | ----------------- | ------------------------------- | | `*` | Full access to all resources | | `messages:send` | Send messages | | `messages:read` | Read message status and history | | `templates:read` | Read templates | | `templates:write` | Create and update templates | | `contacts:read` | Read contact information | | `contacts:write` | Create and update contacts | ## 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` | `forbidden` | Key lacks required permissions | | `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. 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 | `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-4o-mini","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 Write and deploy your first agent in code. Start from a production agent instead of a blank file. Answer and place phone calls. 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 (keywords, intent) 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-4o-mini | \$0.15/1M | \$0.60/1M | \~\$0.0002 | | OpenAI | gpt-4o | \$2.50/1M | \$10/1M | \~\$0.003 | | Anthropic | claude-3-5-haiku | \$0.25/1M | \$1.25/1M | \~\$0.0003 | | Anthropic | claude-3-5-sonnet | \$3/1M | \$15/1M | \~\$0.005 | | Google | gemini-1.5-flash | \$0.075/1M | \$0.30/1M | \~\$0.0001 | If you use the **Zavu** provider, we handle the API key and charge your account balance directly. ## Best Practices Your system prompt defines the agent's behavior. Be specific about tone, limitations, and when to escalate. For data collection or multi-step processes, use flows instead of relying on the LLM to remember steps. Upload FAQs and product docs so the agent can provide accurate, specific answers. Check execution logs regularly. Use smaller models like gpt-4o-mini for cost efficiency. ## Next Steps Configure your first AI Agent Build conversational flows Add webhook integrations 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 | | Activities | 90 days | 180 days | Data retention is applied retroactively. When you reduce the retention period, existing data older than the new limit will be automatically deleted within 24 hours. ## Configuring Retention You can configure the retention period for your project through the [Dashboard](https://dashboard.zavu.dev/data-retention). ### Dashboard 1. Navigate to **Infrastructure > Data Retention** in the sidebar 2. Use the slider to set your desired retention period (90-180 days) 3. Review the estimated impact on your existing data 4. 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. Data exports are available from the Zavu [Dashboard](https://dashboard.zavu.dev/data-retention). For bulk or 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 * **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**: View exactly how much data will be affected before changing settings For complete data deletion requests from specific users, contact our support team. ## Best Practices 1. **Export before reducing retention**: Export important data before reducing the retention period 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 4. **Review impact**: Check the estimated impact before saving retention changes ## 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-4o-mini", 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. ## 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 `ZAVUDEV_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 | 100k | 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 | 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" ``` 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 | 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 | If `availableChannels` is empty, the number cannot receive any messages. This typically happens with landline numbers. 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 Use the introspection API when collecting phone numbers to catch errors early. Always store phone numbers in E.164 format in your database. Some channels only work with mobile numbers. Check `lineType` if channel selection matters. Let users set their preferred channel via the contact's `defaultChannel` field. ## Next Steps Learn how senders work with phone numbers Start sending messages to phone numbers # Rate Limiting Source: https://docs.zavu.dev/concepts/rate-limiting Understanding Zavu API rate limits and how to handle them. Per-endpoint quotas, 429 responses, retry-after headers, and best practices for production ## Rate Limiting Zavu implements rate limiting to ensure fair usage and protect the platform from abuse. All API requests to `/v1/*` endpoints are subject to rate limiting. ## Rate Limit Tiers Your rate limit depends on your project's verification status: | Tier | Limit | Eligibility | | ----------------------- | ------------------ | ------------------------------------------ | | **Default** | 600 requests/min | All new projects | | **Compliance Verified** | 1,200 requests/min | Projects with verified business compliance | | **10DLC Verified** | 2,400 requests/min | Projects with approved 10DLC campaign | Rate limits are applied **per project**. If you have multiple projects, each has its own independent rate limit. ### How to Increase Your Limit Submit your business information and use case in the dashboard under **Settings > Compliance**. Once verified, your limit automatically increases to **1,200 requests/min** (2x). If you're sending SMS to US recipients, register your brand and campaign for 10DLC compliance. Approved campaigns receive **2,400 requests/min** (4x). Verification is automatic - once approved, your rate limit increases immediately with no code changes needed. ## Rate Limit Headers Every API response includes headers to help you track your rate limit usage: | Header | Description | Example | | ----------------------- | -------------------------------------- | --------------- | | `X-RateLimit-Limit` | Maximum requests per minute | `600` | | `X-RateLimit-Remaining` | Requests remaining in current window | `542` | | `X-RateLimit-Reset` | Unix timestamp (ms) when window resets | `1701234600000` | | `Retry-After` | Seconds until reset (only on 429) | `45` | ## Checking Rate Limits with the SDK Use `.withResponse()` to access the rate limit headers: ```typescript TypeScript theme={null} import Zavudev from '@zavudev/sdk'; const zavu = new Zavudev({ apiKey: process.env['ZAVUDEV_API_KEY'], }); // Use .withResponse() to get the full HTTP response const { response, result } = await zavu.messages.send.withResponse({ to: "+14155551234", text: "Hello from Zavu!", }); // Access rate limit headers 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 resets at: ${new Date(Number(reset)).toISOString()}`); // Access the message result as usual 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"), ) response = zavu.messages.send.with_raw_response( to="+14155551234", text="Hello from Zavu!" ) # Access rate limit headers limit = response.headers.get("X-RateLimit-Limit") remaining = response.headers.get("X-RateLimit-Remaining") reset = response.headers.get("X-RateLimit-Reset") print(f"Rate limit: {remaining}/{limit} requests remaining") # Access the parsed result result = response.parse() print(f"Message ID: {result.message.id}") ``` ```ruby Ruby theme={null} require 'zavudev' zavu = Zavudev::Client.new( api_key: ENV['ZAVUDEV_API_KEY'] ) response = zavu.messages.send_with_response( to: "+14155551234", text: "Hello from Zavu!" ) # Access rate limit headers limit = response.headers["X-RateLimit-Limit"] remaining = response.headers["X-RateLimit-Remaining"] reset = response.headers["X-RateLimit-Reset"] puts "Rate limit: #{remaining}/#{limit} requests remaining" # Access the message result puts "Message ID: #{response.data.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")), ) result, err := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{ To: zavudev.String("+14155551234"), Text: zavudev.String("Hello from Zavu!"), }) // Access rate limit headers from the response // Headers are available via the raw HTTP response fmt.Println("Message ID:", result.Message.ID) } ``` ```php PHP theme={null} $zavu = new Zavudev\Client(['apiKey' => getenv('ZAVUDEV_API_KEY')]); $response = $zavu->messages->sendWithResponse([ 'to' => '+14155551234', 'text' => 'Hello from Zavu!', ]); // Access rate limit headers $limit = $response->headers['X-RateLimit-Limit']; $remaining = $response->headers['X-RateLimit-Remaining']; $reset = $response->headers['X-RateLimit-Reset']; echo "Rate limit: {$remaining}/{$limit} requests remaining\n"; echo "Message ID: " . $response->data->message->id . "\n"; ``` ```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 you exceed the rate limit, the API returns a `429 Too Many Requests` response: ```json theme={null} { "code": "rate_limited", "message": "Rate limit exceeded. Retry after 2024-01-15T12:30:00.000Z", "details": { "retryAfter": 1705322400000 } } ``` ## Handling Rate Limits Here's how to properly handle rate limiting in your application: ```typescript TypeScript theme={null} import { Zavu } from "@zavudev/sdk"; import * as errors from "@zavudev/sdk/models/errors"; const zavu = new Zavu({ bearerAuth: process.env.ZAVUDEV_API_KEY, }); async function sendWithRateLimitHandling(to: string, text: string) { try { const { response, result } = await zavu.messages.send.withResponse({ to, text, }); // Check remaining quota const remaining = Number(response.headers.get("X-RateLimit-Remaining")); if (remaining < 10) { console.warn(`Low rate limit: ${remaining} requests remaining`); } return result; } catch (error) { if (error instanceof errors.SDKError && error.statusCode === 429) { // Get retry time from error or headers const retryAfter = error.body?.details?.retryAfter; const waitMs = retryAfter ? retryAfter - Date.now() : 60000; console.log(`Rate limited. Waiting ${waitMs}ms before retry...`); await new Promise(resolve => setTimeout(resolve, waitMs)); // Retry the request return sendWithRateLimitHandling(to, text); } throw error; } } ``` ```python Python theme={null} import os import time from zavu_sdk import Zavu from zavu_sdk.models import errors def send_with_rate_limit_handling(zavu, to: str, text: str): try: response = zavu.messages.send.with_raw_response(to=to, text=text) # Check remaining quota remaining = int(response.headers.get("X-RateLimit-Remaining", 0)) if remaining < 10: print(f"Warning: Low rate limit - {remaining} requests remaining") return response.parse() except errors.SDKError as e: if e.status_code == 429: retry_after = e.body.get("details", {}).get("retryAfter") wait_seconds = (retry_after - time.time() * 1000) / 1000 if retry_after else 60 print(f"Rate limited. Waiting {wait_seconds:.1f}s before retry...") time.sleep(max(wait_seconds, 0)) return send_with_rate_limit_handling(zavu, to, text) raise ``` ```ruby Ruby theme={null} require 'zavudev' def send_with_rate_limit_handling(zavu, to:, text:) response = zavu.messages.send_with_response(to: to, text: text) remaining = response.headers["X-RateLimit-Remaining"].to_i if remaining < 10 puts "Warning: Low rate limit - #{remaining} requests remaining" end response.data rescue Zavudev::RateLimitError => e retry_after = e.retry_after || 60 puts "Rate limited. Waiting #{retry_after}s before retry..." sleep(retry_after) send_with_rate_limit_handling(zavu, to: to, text: text) end ``` ```go Go theme={null} package main import ( "context" "fmt" "time" "github.com/zavudev/sdk-go" ) 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 { if rateLimitErr, ok := err.(*zavudev.RateLimitError); ok { retryAfter := rateLimitErr.RetryAfter if retryAfter == 0 { retryAfter = 60 * time.Second } fmt.Printf("Rate limited. Waiting %v before retry...\n", retryAfter) time.Sleep(retryAfter) return sendWithRateLimitHandling(client, to, text) } return nil, err } return result, nil } ``` ```php PHP theme={null} function sendWithRateLimitHandling($client, string $to, string $text) { try { $response = $client->messages->sendWithResponse([ 'to' => $to, 'text' => $text, ]); $remaining = (int) $response->headers['X-RateLimit-Remaining']; if ($remaining < 10) { echo "Warning: Low rate limit - {$remaining} requests remaining\n"; } return $response->data; } catch (\Zavudev\Exceptions\RateLimitException $e) { $retryAfter = $e->retryAfter ?? 60; echo "Rate limited. Waiting {$retryAfter}s before retry...\n"; sleep($retryAfter); return sendWithRateLimitHandling($client, $to, $text); } } ``` ## How It Works Zavu uses a **fixed window** algorithm for rate limiting: ``` Window: 12:00:00 - 12:00:59 ├── Request 1: OK (remaining: 599) ├── Request 2: OK (remaining: 598) ├── ... ├── Request 600: OK (remaining: 0) └── Request 601: 429 Rate Limited Window: 12:01:00 - 12:01:59 └── Request 1: OK (remaining: 599) ← Counter resets! ``` The counter resets at the start of each minute (e.g., 12:00:00, 12:01:00, 12:02:00). ## Best Practices Always check `X-RateLimit-Remaining` to avoid hitting limits unexpectedly. Use the `Retry-After` header or `retryAfter` field to wait the correct amount of time. For bulk sending, use our [Broadcasts API](/guides/broadcasts/overview) instead of individual requests. Include `idempotencyKey` in requests to safely retry without duplicates. ## Bulk Sending Example For sending many messages, implement a simple rate limiter: ```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 next window if (remaining < 10) { console.log("Approaching rate limit, waiting for next window..."); await new Promise(resolve => setTimeout(resolve, 60000)); remaining = 600; } const { response, result } = await zavu.messages.send.withResponse(msg); remaining = Number(response.headers.get("X-RateLimit-Remaining")); results.push(result); } return results; } ``` For sending to more than 100 recipients, we strongly recommend using the [Broadcasts API](/guides/broadcasts/overview) instead. Broadcasts handle rate limiting, retries, and progress tracking automatically. ## Summary | Tier | Limit | How to Get | | ------------------- | --------- | ------------------------------- | | Default | 600/min | Automatic for all projects | | Compliance Verified | 1,200/min | Complete business verification | | 10DLC Verified | 2,400/min | Register 10DLC brand & campaign | ## Next Steps Learn about compliance requirements 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) | **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. **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 ``` 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 Fallback is **enabled by default**. You can disable it per message by setting `fallbackEnabled: false`: ```json theme={null} { "id": "msg_abc123", "status": "delivered", "channel": "sms", "fallbackEnabled": true } ``` ## 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** | KYC verification + domain setup | [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. 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 | For webhook payload structure and signature verification, see the [Webhooks guide](/guides/receiving-messages/webhooks) and [Webhook Security](/guides/receiving-messages/security). ## Best Practices Set up both SMS and WhatsApp on your sender to enable smart routing and automatic fallback. When initiating conversations, use [WhatsApp templates](/concepts/templates) to avoid 24-hour window issues. Check your [contact](/guides/contacts/overview) metrics to understand channel performance in your region. Restrict senders to specific countries to control costs and compliance. ## Next Steps Connect SMS, WhatsApp, Email, Telegram, and more to your sender Purchase and manage phone numbers for SMS and voice Create WhatsApp templates for outbound messaging 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 # 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) ``` 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 Give each of your customers their own API key with independent spending limits. Track usage per customer for billing. Manage multiple client accounts under one team. Set credit limits to control spending per client. Separate messaging for different departments or product lines while sharing the same balance. 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 ``` 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 Provision sub-accounts via API with optional credit limits Update credit limits, manage API keys, and monitor spending Deactivate sub-accounts and understand the deletion grace period 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. 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. 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 | 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 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 }' ``` 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 | 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" } } }' ``` 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 WhatsApp has character limits. Keep your templates concise and actionable. For transactional messages, always use UTILITY to improve approval chances. Create templates early in development. Approval can take 24-48 hours. 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 Deep dive into WhatsApp template features 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. ## Prerequisites Before registering your brand, complete these steps: 1. **KYC Verification** - Complete identity verification in your [Dashboard](https://dashboard.zavu.dev/kyc) 2. **Business Information Ready** - Have your company details available ## 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. | Ensure your company information matches official records. Discrepancies can delay approval. ## How to Register ### Via Dashboard 1. Navigate to **Compliance** → **Verify** in your [Dashboard](https://dashboard.zavu.dev/compliance/verify) 2. Complete KYC verification if not already done 3. Click **Register 10DLC Brand** 4. Fill in your business information 5. Review and submit ### 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 * **\$35 USD** (one-time fee, charged from your account balance) The $35 fee covers both brand registration ($15) and campaign registration (\~\$20) upfront. Ensure you have sufficient balance before submitting. ## Approval Process After submission, your brand goes through vetting: ``` Submitted → Under Review → Approved/Rejected 1-3 days ``` ### Status Meanings | Status | Description | | ------------ | ------------------------------------ | | **Pending** | Brand submitted, awaiting review | | **Verified** | Brand approved, ready for campaigns | | **Rejected** | Brand rejected (see reason provided) | ### 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 | 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: Register your messaging use case 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 | Monthly Fee | | --------------------------- | ------------------------------- | ----------- | | 2FA / Authentication | One-time passwords, login codes | \$2/month | | Account Notifications | Account updates, alerts | \$2/month | | Customer Care | Support conversations | \$2/month | | Delivery Notifications | Shipping updates, ETAs | \$2/month | | Fraud Alerts | Security warnings | \$2/month | | Marketing | Promotional messages, offers | \$10/month | | Low Volume Mixed | Multiple use cases, low volume | \$2/month | | Polling and Voting | Surveys, feedback collection | \$2/month | | Public Service Announcement | Non-profit, public info | \$2/month | | Security Alerts | Security notifications | \$2/month | Choose the most specific use case for your messaging. Marketing campaigns have higher fees but allow promotional content. ## 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}}" ``` 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 | 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 | Included | Already paid during brand registration (\$35 upfront) | | Monthly Fee | \$2-15 | Recurring | The campaign registration fee (\~$20) is included in the $35 upfront fee charged during brand registration. Only the recurring monthly fee applies separately. ## 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+ | 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: Connect your numbers to the campaign 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. 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 │ └─────────────────┘ └─────────────────┘ └─────────────────┘ $35 USD total Included Ready to send! (charged upfront) 1-7 days 1-3 days ``` The $35 registration fee covers both brand ($15) and campaign (\~\$20) registration. It is charged upfront when you register your brand. ### 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 | \$15 | One-time (charged upfront as part of \$35 total) | | Campaign Registration | \~\$20 | One-time (included in \$35 upfront fee) | | Campaign Monthly Fee | \$2-15/month | Recurring | Costs vary by campaign use case. Marketing campaigns have higher fees than transactional ones like 2FA. ## 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+ | 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. **Completed KYC verification** - Identity verification for your account 2. **US phone number** - At least one number to assign to your campaign 3. **Business information** - Legal name, EIN, address, website ## Next Steps Start with brand registration 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 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" ``` ## 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 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 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 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 KYC verification ↓ 2. Register Brand ($35 total upfront) → Wait for approval (1-3 days) ↓ 3. Create Campaign (included in $35) → Wait for approval (1-7 days) ↓ 4. Assign Phone Numbers → Ready to send! ``` Review the full process 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. **KYC verification** completed for the parent account 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 | \$35 one-time | | Campaign monthly fee per sub-account | \$2-15/month | Each sub-account requires its own brand registration ($35). The fee covers both brand ($15) and campaign (\~\$20) registration. ## 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 Learn about sub-accounts 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 | Use flows when you need **guaranteed outcomes**. Use the LLM when you need **flexibility**. ## Flow Triggers Two trigger types run today: | Trigger Type | Description | Example | | ------------ | ------------------------------------------------------------------- | --------------------------------- | | `keyword` | Message contains any of the words, case-insensitive substring match | "book", "appointment", "schedule" | | `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. `intent` and `manual` are accepted by the API and stored on the flow, and the matcher has no branch for either: a flow created with one is never triggered by anything and never says so. Use `keyword` 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. ### 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" } ``` 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, so the plain agent takes the conversation from the next message. 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. **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 Go to **Senders** > select your sender > **Agent** tab > **Flows** section. 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 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 Set up how the flow is triggered: * For **keyword** triggers, enter the words that activate the flow * For **intent** triggers, describe the intent in natural language * For **always**, the flow runs on every new conversation Use the **Test** button to simulate a conversation and verify each step works correctly. 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." } } ] }' ``` ### 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 **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 Aim for 5-7 steps maximum. Long flows have higher abandonment rates. Validate critical inputs (email, phone) immediately after collection. Let users say "stop" or "cancel" to exit the flow at any time. Reference collected data in messages to personalize the experience. ## Next Steps Enable your flows to execute actions via webhooks 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. 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 Go to **Senders** > select your sender > **Agent** tab > **Knowledge Bases** section. Click **Create Knowledge Base** and enter: * **Name**: A descriptive name (e.g., "Product FAQs") * **Description**: What this knowledge base contains 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 Documents are automatically chunked and embedded. This takes a few seconds for small documents, longer for large PDFs. You'll see a processing indicator while chunks are being created. The agent can only use fully processed documents. 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 ## Supported File Types | Type | Extensions | Max Size | Notes | | ------------ | ------------ | -------- | ------------------------- | | **Text** | Direct input | 100 KB | Plain text content | | **Markdown** | `.md` | 100 KB | Preserves formatting | | **PDF** | `.pdf` | 10 MB | Text extraction only | | **URL** | Web pages | - | Fetches and extracts text | PDF processing extracts text only. Images, charts, and tables within PDFs are not processed. ## Document Limits | Limit | Value | | ---------------------------- | ------ | | Documents per Knowledge Base | 100 | | Knowledge Bases per Agent | 10 | | Max document size (text/md) | 100 KB | | Max document size (PDF) | 10 MB | | Max chunks per document | 500 | ## Best Practices Use headers, bullet points, and clear sections. Well-structured content creates better chunks. Include specific answers to common questions. The more explicit, the better the retrieval. Update documents when information changes. Outdated content leads to incorrect answers. 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. ``` 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 Configure your AI agent settings Let your agent execute actions Build structured conversation paths 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-1.5-pro, gemini-1.5-flash | 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 | 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 Choose your AI provider and model. Configure system prompts, temperature, and context window size. Create structured conversation paths for lead capture, booking, and multi-step processes. Let the AI execute actions by calling your webhooks (check orders, create tickets, etc.). 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-4o-mini | \$0.15/1M tokens | \$0.60/1M tokens | \~\$0.0002 | | OpenAI | gpt-4o | \$2.50/1M tokens | \$10/1M tokens | \~\$0.003 | | Anthropic | claude-3-5-haiku | \$0.25/1M tokens | \$1.25/1M tokens | \~\$0.0003 | | Anthropic | claude-3-5-sonnet | \$3/1M tokens | \$15/1M tokens | \~\$0.005 | | Google | gemini-1.5-flash | \$0.075/1M tokens | \$0.30/1M tokens | \~\$0.0001 | For most use cases, **gpt-4o-mini** or **gemini-1.5-flash** offer the best balance of quality and cost. Reserve larger models for complex reasoning tasks. ## Next Steps Configure your first AI Agent step-by-step Build structured conversation paths Add custom actions via webhooks 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 exactly one agent, so they cannot reach an agent with no sender, or the second agent on a shared one. ```bash theme={null} npx zavudev agents senders connect --agent --sender npx zavudev agents senders disconnect --agent --sender ``` 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, nothing is charged, and no execution is logged — so it is safe to run on every edit while you shape the prompt. ```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. It also warns about what a dry run cannot prove: an agent that is still disabled, tools its channels will never call, 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 Go to **Senders** in the sidebar, select your sender, and click the **Agent** tab. Toggle the **Enable Agent** switch to activate automated responses for this sender. 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) 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 ``` Select which channels should trigger the agent: * **SMS**: Text messages * **WhatsApp**: WhatsApp messages You can enable the agent on both channels or just one. 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 | 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 ```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" ``` ## 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 Define exactly what the agent should and shouldn't do. Include examples of good responses. Specify what topics to avoid and when to escalate to humans. For SMS, instruct the agent to keep responses under 160 characters when possible. 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 Always test your agent before enabling it for production traffic. Poorly configured agents can frustrate customers. ## Next Steps Create structured conversation paths Let your agent execute actions Upload documents for the agent to reference 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 | 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 two scopes: read your busy times, and write the events the agent books. 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. 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 Build a tool against your own endpoint. 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. **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. **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 Go to **Senders** > select your sender > **Agent** tab > **Tools** section. 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 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"] } ``` Add a **Webhook Secret** to verify requests are from Zavu. We'll sign each request with this secret. Use the **Test** button to send a sample request to your webhook and verify it responds correctly. 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 The AI uses the description to decide when to use the tool. Be specific about when it should and shouldn't be used. Webhook URLs must use HTTPS for security. We reject HTTP endpoints. Return meaningful error messages so the agent can inform the user appropriately. 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. ``` 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 Let your agent answer questions from documents 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` | 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" } ] } ``` 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 }); ``` 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 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%", }, }, }); ``` 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", }); ``` 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); ``` 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 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 Add up to 1,000 contacts per request. Zavu handles rate limiting and delivery optimization automatically. Customize each message with per-contact template variables like names, order IDs, or custom fields. Schedule broadcasts for future delivery. Perfect for time-zone aware campaigns. 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. | | `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. | ## 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 | 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 Configure message content and channel settings Batch upload and personalization Send immediately or schedule for later 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" } ``` 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" }' ``` 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" }' ``` 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 When you call `/send`, Zavu calculates the estimated cost based on contacts and channel rates If your available balance (excluding other reservations) is less than the estimated cost, the request fails The estimated amount is reserved and cannot be used for other broadcasts or messages As messages are delivered, actual costs are deducted from the reservation 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: ```json theme={null} { "code": "insufficient_balance", "message": "Insufficient balance for this broadcast", "details": { "estimatedCost": 25.00, "availableBalance": 10.50, "reservedBalance": 15.00, "totalBalance": 25.50, "requiredAmount": 14.50 } } ``` 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 | 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 When you call `/send`, the broadcast enters `pending_review` status Content is analyzed for spam, phishing, prohibited content, and policy violations 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" ``` 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" ``` 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 that hasn't completed: ```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 | Original Status | Action | | --------------- | -------------------------------------------------------- | | `draft` | Broadcast deleted | | `scheduled` | Returns to `draft`, can be rescheduled | | `sending` | Stops new messages, pending contacts marked as `skipped` | 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) 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 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, "delivered": 2350, "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 | | `delivered` | number | Successfully delivered | | `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}!`); ``` 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 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 5 seconds for small broadcasts, 30 seconds for large ones Some contacts may fail while others succeed - check both counts Review failed contacts to improve your contact list quality 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-4o-mini 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" ``` **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-4o-mini \ --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. 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"}' ``` 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. 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 ``` `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. **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 ✓** — warnings print before the success line, and the command exits non-zero when your declarations were not synced at all. 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. | | `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. | `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`. 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 What a deploy actually does, with diagrams. 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` ``` 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" ``` 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 Search for contacts matching a phone number prefix: ```typescript theme={null} // Find all contacts with US country code const { items } = await zavu.contacts.list({ phoneNumber: "+1", limit: 50, }); ``` ## 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 | | ---------------- | -------------------------------------------------------------------- | | `defaultChannel` | Preferred channel for smart routing (sms, whatsapp, telegram, email) | | `metadata` | Custom key-value pairs (merged with existing metadata) | 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.`, }); ``` 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"] } ``` 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 Learn how defaultChannel affects message routing Send messages to many contacts at once Validate and lookup phone numbers Receive updates when contacts reply ## API Reference Manage contacts programmatically: * [List contacts](/api-reference/list-contacts) — paginated list of contacts in your project * [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) — modify name, attributes, or channels * [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 | 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 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::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 Suspensions are typically resolved within 24-48 hours after you've demonstrated list cleanup. ## Best Practices Summary * Use double opt-in * Clean lists regularly * Monitor bounce rates weekly * Handle bounces in webhooks * Remove unsubscribes immediately * 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 using sandbox or custom domains Email is configured as a **channel** on a sender profile. You can start testing immediately with a sandbox domain, or add your own custom domain for production. ## 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 *** ## Sandbox Email (Quick Start) Sandbox domains let you test email sending immediately without configuring DNS records. Sandbox emails are limited to 100 emails/hour. No KYC required. Use a custom domain for production. Go to **Sender Profiles** and click on the sender you want to configure. Click the **Channels** tab in the sender detail page. Sender channels tab Click the **Email** channel card to open the email configuration dialog. The dialog opens on the **Sandbox (Free)** tab by default. Click **Activate Sandbox Email** to create your sandbox domain. Email channel card Once the sandbox domain is created, configure: * **From Email Address**: The local part before `@` (e.g., `noreply`) * **From Name**: The display name recipients see (e.g., `Your Company`) * **Reply-To Email** (optional): Where replies go * **Enable Email Receiving**: Toggle this on if you want to receive inbound emails on this address Click **Save** to enable the email channel on your sender. ### 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 Sandbox", text: "If you see this, your sandbox email 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 Sandbox", text="If you see this, your sandbox email 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 Sandbox", text: "If you see this, your sandbox email 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 Sandbox"), Text: zavudev.String("If you see this, your sandbox email 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 Sandbox', 'text' => 'If you see this, your sandbox email 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 Sandbox", "text": "If you see this, your sandbox email is working!" }' ``` If you have multiple senders, use the `Zavu-Sender` header to specify which sender to use. Otherwise, the default sender is used. ### Sandbox vs Custom Domain | Feature | Sandbox | Custom Domain | | --------------------- | -------------------------------- | ------------------------------- | | **Rate limit** | 100 emails/hour | Based on your plan | | **From address** | `*@yourproject.sandbox.zavu.dev` | `*@yourdomain.com` | | **Deliverability** | Lower (sandbox reputation) | Higher (your domain reputation) | | **Inbound receiving** | Supported | Supported (requires MX record) | | **KYC required** | No | Yes | *** ## Custom Domain Email For production use, add your own domain to send from addresses like `noreply@yourcompany.com`. Go to **Sender Profiles** > your sender > **Channels** tab > click the **Email** card. Click the **Custom Domain** tab at the top of the dialog. Custom domain tab Select **Add new domain** from the dropdown and enter your domain (e.g., `yourcompany.com`). Add domain Zavu displays the DKIM CNAME records you need to add to your DNS. Copy each record and add them to your DNS provider: DKIM records table 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 ``` 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. Click **Verify DNS** to check if your records have propagated. Once verified, the domain status changes to **Verified** and the from address fields appear. Set your: * **From Email Address**: e.g., `noreply` * **From Name**: e.g., `Your Company` * **Reply-To Email** (optional): e.g., `support@yourcompany.com` Click **Save** to enable the email channel with your custom domain. Email sending with custom domains requires KYC verification. Complete identity verification in the dashboard before sending production emails. ### 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!" }' ``` 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. This works for both sandbox and custom domains. ### For Custom Domains: Add MX Record Go to your sender > **Channels** > **Email** card. Under **Enable Email Receiving**, Zavu shows the MX record to add to your DNS: ``` yourcompany.com MX 10 inbound.zavu.dev ``` After adding the record, click **Verify MX**. Once verified, the toggle becomes available. Toggle **Enable Email Receiving** on and save. ### For Sandbox Domains Toggle **Enable Email Receiving** directly in the sandbox email configuration and save. No MX record needed. ### 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..." } } ``` 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. Catch-all builds on receiving, so **Enable Email Receiving** must be on (the domain's MX record must be verified). See the steps above. 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 }' ``` 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, click **Verify DNS** | | `email_kyc_required` error | KYC not completed | Complete identity verification in dashboard | | Emails going to spam | Using sandbox domain | Switch to a custom domain for production | | 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 `ZAVUDEV_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-4o-mini, 3-turn convo) | \$0.0001–0.0005 | | 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 Complete booking agent with persistence. 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. 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-4o-mini", 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 | | ---------- | -------------------------------------------------- | --------------------------------------------------------------------------------- | | `senderId` | string | The `_id` of an existing sender in your project. Usually `process.env.SENDER_ID`. | | `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. Multi-line OK (use backticks). | ## Optional fields | Field | Default | Description | | ------------------------ | ---------------- | ------------------------------------------------------------------------------------------ | | `channels` | `["*"]` | Which channels trigger the agent: `whatsapp`, `sms`, `telegram`, `email`, `*`. | | `messageTypes` | `["text"]` | Message types: `text`, `image`, `audio`, … | | `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-4o-mini", // 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: `…`, }) ``` Voice requires the **Voice Agents** feature enabled for your team and 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-4o-mini", 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-4o-mini", 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-4o-mini", // provider/model id, the gateway picks the right backend }) ``` Available models on the gateway: | `model` | Backend | Best for | | -------------------------------------- | ------------------ | ------------------------- | | `openai/gpt-4o-mini` | OpenAI GPT-4o mini | Cheapest, fastest | | `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-1.5-flash` | Google Flash | Cheap multilingual | | `google/gemini-1.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-1.5-flash`, etc) — no `provider/` prefix when using BYOK. 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 **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. ## One file A Zavu function deploys as a **single file**. The build worker writes your source beside its wrapper and runs esbuild on that pair, so a file next to yours is not there: ```ts theme={null} import { formatOrder } from "./helpers" // will not deploy ``` The CLI catches this before uploading and tells you which imports are the problem. npm dependencies are fine: declare them under `dependencies` in `package.json` and they are installed and bundled. If the agent genuinely needs several files, deploy the repository instead: ```sh theme={null} npx zavudev fn git link acme/my-agent --branch main ``` Every push to that branch redeploys. The [eve layout](/guides/frameworks/eve) is also multi-file. ## 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**. Future edits are blocked in the dashboard. | | Agent managed by THIS function | Update fields to match the manifest. | | 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. 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 To pause an agent without removing the code: ```ts theme={null} defineAgent({ ..., enabled: false, }) ``` Redeploy. The agent stays in the database; messages to its sender will be processed by webhooks instead. ## Common patterns 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. 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.` ``` 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}…`, }) ``` 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 Give the agent actions to execute. 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" }, } ``` 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." 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 `ZAVUDEV_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.ZAVUDEV_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. 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. 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 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, } } ``` 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." } }, }) ``` 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 } ``` 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 Inject DB credentials and API keys. 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. 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 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. ```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 }, }) ``` 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. 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 Order tracking, recommendations, cart recovery. 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.ZAVUDEV_API_KEY!, baseURL: process.env.ZAVU_API_BASE_URL, }) defineAgent({ senderId: process.env.SENDER_ID!, name: "Shop Helper", provider: "zavu", model: "openai/gpt-4o-mini", 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-4o-mini ≈ $0.0002 / 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 Black Friday: broadcast a 20% off code to your list. The agent picks up when customers reply with questions. For the initial "your order is shipped" notification (outside the 24h window), use a WhatsApp template instead of the agent. Store `lastOrderId`, `lifetimeValue`, `preferredCategory` on contacts. The agent reads them automatically via `includeContactMetadata: true`. Run separate agents for different storefronts under the same parent Zavu account. ## Next JSON schemas, error handling, the LLM's selection logic. 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. 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-4o-mini", 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 `ZAVUDEV_API_KEY` already lets you do this: ```ts theme={null} import Zavudev from "@zavudev/sdk" const zavu = new Zavudev({ apiKey: process.env.ZAVUDEV_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-4o-mini via Zavu gateway, \~3 turns) | \~\$0.0006 | \~\$0.90 | | 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 Knowledge base lookup + ticket creation. 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. 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)) and the **Voice Agents** feature enabled for your team. ## 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 ``` `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 whichever of its `channels` are connected to the sender. ## 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-4o-mini", 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 } }, }) ``` `--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 `. 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 Confirm the sender has a phone number assigned and the **Voice Agents** feature is enabled for your team (voice endpoints return `403` otherwise). Then check the deploy printed `Agents synced: + Fermi` and that the `voice` block has `enabled: true`. 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. 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 Providers, models, prompts, and the voice block. Schemas, handlers, returning structured data. Greetings, languages, voicemail, and transfer. 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-4o-mini", 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 Customer asks something, the LLM decides which tool to call, your code runs, real data goes back as the answer. Receive inbound from Stripe, GitHub, your own systems, then forward as a WhatsApp message. Daily report digests, reminder broadcasts, cleanup tasks. 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 `ZAVUDEV_API_KEY` in its environment so it can call our SDK without you handling key distribution. ## Quick tour 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 ``` ```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 ``` ```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. ```sh theme={null} npx zavudev fn init --template restaurant-booking -y cd reservations ``` Creates `index.ts` with `defineAgent` + `defineTool` boilerplate. ```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`. ```sh theme={null} npx zavudev deploy ``` Bundle, upload, agent + tools synced to your sender. Send a WhatsApp to the sender's number. The agent answers, calls your tool handlers, returns real data. ## Continue Full step-by-step from zero to live agent in 10 minutes. Every `zavu` command, flag by flag. `defineAgent` API, providers, prompts, models. `defineTool` API, schemas, handlers, error handling. Environment variables encrypted at rest. Listen to Zavu events from your function. How pinning works, when to upgrade. 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. 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. **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`. 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-4o-mini", 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 ``` 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-4o-mini ``` **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 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`). 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`. 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. 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`. 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 Providers, models, prompts, triggers. Schemas, handlers, returning structured data. Knowledge base lookup + ticket creation. 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. ## 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.runtimeVersion' ``` ``` "v7" ``` Same version across deploys means same runtime. ## 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, runtimeVersion, deployedAt}' ``` ```json theme={null} { "version": 4, "runtimeVersion": "v7", "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 | Status | Receives security patches | Accepts new deploys | Functions keep running | | ----------------- | ------------------------- | ------------------- | ---------------------- | | `active` (latest) | ✅ | ✅ | ✅ | | `deprecated` | ✅ for 6 months | ✅ | ✅ | | `eol` | ❌ | ❌ (deploy fails) | ✅ existing only | When a version moves to `deprecated` or `eol`, we email project owners 60 days before the transition with the affected function list. "EOL" never means the runtime disappears. Deployed functions keep their pinned runtime indefinitely — EOL only blocks new deploys against that version. ## 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 and the previous runtime is still `active`/`deprecated` (not yet EOL), 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. 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 ``` 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 | | -------------------- | ------------------------------------------------------- | --------------------------------------------- | | `ZAVUDEV_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.ZAVUDEV_API_KEY!, baseURL: process.env.ZAVU_API_BASE_URL, }) ``` `ZAVUDEV_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 ```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. ``` 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. 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 ``` 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. 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 ```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. ```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 ``` ```ts theme={null} export default defineFunction(async (event, ctx) => { if (event.type !== "conversation.new") return const zavu = new Zavudev({ apiKey: process.env.ZAVUDEV_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. # Integrations Source: https://docs.zavu.dev/guides/integrations/overview Connect Zavu with your favorite tools and platforms Zavu integrates with popular platforms to streamline your messaging workflows. Connect once and start sending messages from anywhere in your stack. ## Available Integrations Automatic API key provisioning and environment variable injection for your Vercel projects. ## Coming Soon We're actively working on integrations with these platforms: Connect Zavu to 5,000+ apps with no-code automation workflows. Send payment confirmations, failed payment alerts, and subscription reminders. Order confirmations, shipping updates, and abandoned cart recovery. Trigger messages from database events and Edge Functions. Phone verification and two-factor authentication via SMS. CRM-triggered messaging for sales and marketing automation. ## Request an Integration Don't see the integration you need? We'd love to hear from you. Email us at [support@zavu.dev](mailto:support@zavu.dev) with your integration request and use case. ## Build Your Own Zavu's API makes it easy to build custom integrations. Use webhooks to receive real-time events and our SDKs to send messages from any platform. Receive delivery status updates and incoming messages in real-time. Full REST API documentation for custom integrations. # 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` and `ZAVU_PROJECT_ID` are added to your Vercel projects * **Multi-Project Support**: Connect multiple Vercel projects to a single Zavu project ## Installation Visit the [Zavu Integration on Vercel Marketplace](https://vercel.com/integrations/zavu) and click **Add Integration**. Grant Zavu permission to access your Vercel account and projects. Choose an existing Zavu project or create a new one to connect with your 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 | | `ZAVU_PROJECT_ID` | Your Zavu project ID | 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 Projects To disconnect a Vercel project: 1. Go to your Vercel Dashboard 2. Navigate to **Settings > Integrations** 3. Find Zavu and click **Configure** 4. Remove the integration from specific projects Disconnecting the integration will **not** automatically remove the environment variables from your Vercel project. You may need to remove them manually. ### 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. Check that your Zavu project is not in sandbox mode (or that you're sending to verified numbers) 3. Review the [API Logs](https://dashboard.zavu.dev/api-logs) for detailed error messages ## Use Cases Send order confirmations, shipping updates, and password resets Implement SMS-based 2FA for your Next.js applications Enable WhatsApp messaging for real-time customer communication Run targeted SMS and WhatsApp marketing campaigns ## Next Steps Learn different ways to send messages Send rich WhatsApp messages with media and buttons Receive delivery status updates and incoming messages 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 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 ``` 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** 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. 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 }' ``` 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" }' ``` 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" ``` 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 --> expired: Time expires pending --> cancelled: Cancel invitation in_progress --> completed: Setup successful in_progress --> failed: Client cancelled or abandoned in_progress --> expired: Time expires in_progress --> cancelled: Cancel invitation failed --> in_progress: Client retries the same link completed --> [*]: Sender created expired --> [*] cancelled --> [*] ``` 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. ## 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 Set up a webhook to be notified when invitations are completed: ```typescript theme={null} // In your webhook handler app.post('/webhooks/zavu', async (req, res) => { const event = req.body; if (event.type === 'sender.created') { // A new sender was created (possibly from an invitation) const sender = event.data; console.log('New sender:', sender.id, sender.phoneNumber); // Update your system await updateCustomerWhatsAppStatus(sender.phoneNumber, 'connected'); } res.status(200).send('OK'); }); ``` ## 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::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 Save the invitation ID in your database to track status and link to your customer records. If an invitation is pending for several days, send a reminder to your client. Monitor for expired invitations and automatically create new ones if needed. Set up webhooks to get real-time notifications when senders are created. ## 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 Learn to create invitations from the dashboard Work with senders created from invitations Start sending messages through your new senders Purchase phone numbers to pre-assign to invitations # Onboarding Clients via Dashboard Source: https://docs.zavu.dev/guides/partner-invitations/dashboard Generate invitation links from the dashboard to let clients connect their WhatsApp Business account or Facebook Page This guide walks you through onboarding clients to connect a Meta channel using the Zavu dashboard. ## Overview Partner invitations allow you to: * Generate unique invitation links for your clients * Let clients connect WhatsApp Business or a Facebook Page themselves * Optionally pre-assign a Zavu phone number for WhatsApp invitations * Track invitation status and manage multiple clients ## Creating an Invitation ### Step 1: Navigate to Partner Invitations 1. Log in to your [Zavu Dashboard](https://dashboard.zavu.dev) 2. Select your project from the sidebar 3. Click on **Partner Invitations** in the navigation ### Step 2: Create a New Invitation 1. Click the **New Invitation** button 2. Fill in the invitation details: | Field | Required | Description | | --------------- | -------- | --------------------------------------------------------- | | Connection Type | Yes | What the client connects: WhatsApp Business or Messenger | | Client Name | No | A name to identify this client (e.g., "Acme Corp") | | Client Email | No | The client's email address for your reference | | Phone Number | No | Optionally pre-assign a Zavu phone number (WhatsApp only) | | Link Expires In | Yes | How long the invitation link is valid (1-30 days) | One invitation connects one channel. To onboard a client on both WhatsApp and Messenger, create one invitation per channel; each produces its own sender. ### Connecting a Facebook Page (Messenger) Pick **Messenger** as the connection type. Your client opens the link, authorizes with Facebook, and picks a Page they administer. The Page's Messenger inbox, including Marketplace chats, is then routed to Zavu. They need admin access to at least one Facebook Page. No phone number is involved, so the phone number options are hidden for this connection type. 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 stops receiving messages. ### Step 3: Choose Phone Number Option You have three options for the WhatsApp phone number: The client will use their own phone number during WhatsApp Business setup. Choose this when your client already has a number they want to use. Select a phone number from your account to pre-assign to this invitation. The client will register this number with their WhatsApp Business Account. The client connects their existing WhatsApp Business App account. They keep using the app on their phone while also enabling Cloud API messaging. Pre-assigning a phone number is useful when you want to provision numbers for your clients or when clients don't have a suitable number for WhatsApp Business. ### WhatsApp Business App Connection (Coexistence) If your client is already using the WhatsApp Business App on their phone and wants to keep using it while adding Cloud API capabilities, choose the **WhatsApp Business App** option. This enables **coexistence mode** where the same phone number works on both: * **WhatsApp Business App** - For personal 1:1 conversations from their phone * **Cloud API (via Zavu)** - For automated messages, broadcasts, and integrations Coexistence mode has some limitations: * Fixed throughput of 80 messages per second * Not available in Nigeria and South Africa * Maximum 4 linked devices * Edit and revoke messages disabled in the app When your client completes the setup with this option, they can optionally sync their existing message history and contacts into Zavu. Learn more about [WhatsApp Business App Connection](/guides/whatsapp/business-app). ### Step 4: Share the Invitation Link After creating the invitation: 1. Copy the generated invitation link 2. Send it to your client via email, chat, or any preferred method 3. The client will use this link to complete the WhatsApp Business setup ## What Your Client Sees When your client opens the invitation link, they'll see: 1. **Welcome Page** - Shows your company name and setup instructions 2. **Requirements** - What they need: * A Facebook account with admin access to their business * A phone number for WhatsApp (unless pre-assigned) * About 5 minutes to complete setup 3. **WhatsApp Setup** - They'll complete Meta's embedded signup flow: * Log in with Facebook * Select or create a WhatsApp Business Account * Register and verify the phone number 4. **Confirmation** - Success message when setup is complete ### Pre-assigned Phone Number Flow If you pre-assigned a Zavu phone number: 1. The client sees the phone number on the invitation page 2. They copy this number during Meta's signup flow 3. When prompted for verification, they select **Phone Call** 4. Zavu automatically receives the verification code 5. The code is displayed on the invitation page for the client to enter When using a pre-assigned number, instruct your client to select "Phone Call" for verification instead of SMS, as this provides the smoothest experience. ## Managing Invitations ### Invitation Statuses | Status | Description | | --------------- | ---------------------------------------------------------------------------------------------- | | **Pending** | Invitation created, waiting for client to start | | **In Progress** | Client has started the setup | | **Completed** | Channel connected successfully, sender created | | **Failed** | Client started and didn't finish. Hover the badge for the reason; the same link can be retried | | **Expired** | Invitation expired before completion | | **Cancelled** | You cancelled the invitation | ### Actions From the invitations list, you can: * **Copy Link** - Copy the invitation URL to share with your client * **Cancel** - Invalidate the invitation so it can't be used * **Resend** - Generate a new link for expired or cancelled invitations ## After Completion When a client completes the WhatsApp setup: 1. A new **sender** is automatically created in your project 2. The sender has WhatsApp enabled with the registered phone number 3. The invitation status changes to **Completed** 4. You can immediately start sending WhatsApp messages through this sender ### Finding the New Sender 1. Go to **Senders** in your dashboard 2. Look for the sender with the client's WhatsApp number 3. The sender ID is also shown in the completed invitation details ## Best Practices Use shorter expirations (3-7 days) for urgent onboarding. Longer expirations may lead to stale invitations. Always fill in the client name and email to easily track and manage invitations. Use pre-assigned numbers when you want to manage phone numbers centrally or when clients need number provisioning. If an invitation is pending for more than a few days, reach out to your client to assist with the setup. ## Troubleshooting ### Client Can't Complete Setup Common issues and solutions: | Issue | Solution | | -------------------------------- | ------------------------------------------------------------------------------------------------------------ | | "Invalid invitation" error | The link may be expired or cancelled. Create a new invitation. | | Facebook login fails | Client needs admin access to a Facebook Business account. | | Phone number already on WhatsApp | The number must not be registered on personal WhatsApp. Client needs to delete their WhatsApp account first. | | Verification code not received | For pre-assigned numbers, ensure client selected "Phone Call" verification. | ### Invitation Stuck in "In Progress" If an invitation shows "In Progress" for an extended time: 1. The client may have started but not completed the setup 2. Contact the client to check their progress 3. If needed, cancel and create a new invitation ## Next Steps Automate invitation creation with the API Start sending WhatsApp messages through your new senders # 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.