# Handsent: the full guide > Handsent sends personal, one-to-one email to a list from your own Gmail: one email per person, merged with their details, at a human pace, with follow-ups in the same thread that stop when someone replies. It reads Google Sheets and CSVs, writes status back to the sheet, tracks replies, bounces and unsubscribes without tracking pixels, and exposes everything through a REST API, signed webhooks and an MCP server. Made by Buildnode (hello@buildnode.io). Free during early access. This file is written for people and for coding agents. Everything in it is derived from the application source. Where a detail is not exposed by the API yet, the guide says so. - App: https://handsent.buildnode.io - REST API: https://handsent.buildnode.io/api/v1 (OpenAPI 3.1 at https://handsent.buildnode.io/api/v1/openapi.json) - MCP: https://handsent.buildnode.io/api/mcp - Short index: https://handsent.buildnode.io/llms.txt - Privacy: https://handsent.buildnode.io/privacy. Terms: https://handsent.buildnode.io/terms ## What Handsent is, and is not Email software comes in three kinds. - Newsletter and marketing platforms (Mailchimp, Substack, HubSpot) send one designed message from their servers to a subscriber list, with a template, a footer and open tracking. Right for announcements to people who subscribed. - Transactional email (SendGrid, Postmark, Amazon SES) is your application sending receipts, password resets and notifications through an API. Right for anything a machine triggers. Nobody replies. - Handsent sends one email per person from your own Gmail account, written by you, merged with the person's details, at the pace a person would type, with follow-ups threaded under the first email. Right when you want a reply and the email should read like something you sat down and wrote. Handsent is for people you already talk to or have a lawful basis to contact: customers, leads who asked to hear from you, partners, candidates, investors, and businesses with a published contact (for example, telling a local business that its listing on your directory is live). It is designed to make anything else hard: caps, send windows, a required mailing address and unsubscribe line, workspace-wide suppression, and no way to track opens. ## 1. How to set up ### 1.1 Sign in with Google Go to https://handsent.buildnode.io and choose "Continue with Google". Google will ask for three permissions. Each one is used for exactly one feature: | Scope | Used for | |---|---| | https://www.googleapis.com/auth/gmail.send | Sending the campaign emails and test emails you start. Sent mail appears in your Gmail Sent folder. | | https://www.googleapis.com/auth/gmail.readonly | Reading only the threads Handsent started, to detect replies, bounces and unsubscribe requests, and reading a Gmail draft you choose as a template. Handsent never reads the rest of your inbox. | | https://www.googleapis.com/auth/spreadsheets | Reading the Google Sheet you connect as a list and writing each row's status back to it. | Handsent's use of Google data follows the Google API Services User Data Policy, including the Limited Use requirements. Tokens are deleted when you disconnect an inbox, and you can revoke access at any time from your Google account. Signing in creates a workspace (an organization) for you. A workspace owns inboxes, lists, templates, campaigns, suppressions, API keys and webhooks. Other people can be invited from the Team page and each connects their own Gmail as an inbox. ### 1.2 Connect a list On the Lists page you can: - Connect a Google Sheet: paste its URL. The header row becomes the merge fields; one column must contain email addresses. Handsent adds three columns if they are missing, `gmerge status`, `gmerge last sent` and `gmerge note`, and writes each row's status back as the campaign runs. Typing `skip` or `stop` in a row's status cell holds that person; Handsent never overwrites a cell that says so. - Upload a CSV, or paste rows by hand. - Create an API list and push contacts to it from your own code (section 4). Contacts are unique per list by email. Every contact has `fields` (a map of column name to string), a `status` (active, skipped, suppressed, invalid) and an optional note. Malformed addresses are kept as `invalid` rows so one bad cell cannot reject an import. ### 1.3 Set the mailing address On the Team page, "Mailing address" tab, enter your postal address and a default timezone. Every campaign body must contain a physical mailing address and an unsubscribe instruction (for example "Reply STOP to unsubscribe") before it can start. This is what CASL, the CAN-SPAM Act and Gmail's sender guidelines require of commercial email, and Handsent checks for it instead of leaving it to memory. A campaign can set `compliance: "off"` to turn the check into a warning; the default is `strict`. ### 1.4 Write the first campaign Campaigns > New. Pick the inbox to send from and the list to send to, then write the first email: a subject and a body, with merge fields like `{{first_name}}` and `{{company}}` that map to the list's columns. The editor supports rich text and inline images. You can instead point a step at a Gmail draft by its subject (`gmailDraftSubject`), and Handsent reads that draft from your account at send time. Add follow-ups: each one is a step with `afterDays` (at least 1) and its own body. A follow-up with an empty subject is sent as a reply in the same thread, "Re:" the original. Follow-ups are sent only to people who have not replied, bounced or unsubscribed. Out-of-office and other automatic replies do not count as replies. Set pacing (or keep the workspace defaults), an optional start time, and optional waves. Save the content as a template if you want to reuse it. ### 1.5 Test, then start Send yourself a test: Handsent renders the first email with a real contact's fields and sends it to the address you give. A campaign cannot be started until it has been test-sent at least once (the API and MCP can pass `force` to skip this, which is meant for automation you trust). A dry run simulates the next batch without sending and shows who would be emailed and who would be skipped, and why (cooldown, suppression, limits, an unresolved merge field). Start. A worker process wakes every minute and, for each active campaign, refreshes the list, checks threads that are due for a follow-up, sends what the limits allow, writes status back to the sheet, delivers webhooks and, if `notifyEmail` is set, sends a daily summary. Pause with one click at any time; replies and bounces keep being tracked while paused. Resume when ready. ## 2. What it can do - Campaigns: a first email plus up to ten follow-ups, each threaded under the original. Status is draft, active, paused or finished. Inbox and list can be changed while a draft; the number of steps is fixed once started. - Pacing (`limits`): `perDay` and `perHour` caps for the campaign, a `gapSeconds` range like `[45, 150]` for a jittered pause between sends, a `window` like `09:00-17:00`, `days` like `mon-fri`, and an IANA `timezone`. Defaults: 90 a day, 30 an hour, 45 to 150 seconds apart, 09:00 to 17:00, Monday to Friday, America/Vancouver unless the workspace sets another. Each inbox also has its own daily cap across all campaigns (default 300). - Scheduling: `startAt` holds all sending until that instant. `schedule.waves = { size, everyHours }` releases the list in slices, for example 50 people every 24 hours, so replies stay manageable. - Cooldown: `cooldownDays` (default 90) stops a campaign from emailing anyone who was emailed by any campaign in the workspace within that many days. - Suppressions: a workspace-wide do-not-contact list. Addresses land on it when a person replies "STOP" or "unsubscribe" (or similar wording), when a message bounces, when Gmail's one-click unsubscribe is used (Handsent adds a `List-Unsubscribe` mailto header to every message), or when you add them by hand or by API. Suppression and cooldown are re-checked at the moment of sending. - Reply tracking: Handsent re-reads a thread immediately before every follow-up and marks the person replied, auto_replied, bounced or unsubscribed. No tracking pixels, no rewritten links, nothing is added to your email except the unsubscribe header. - Sheet write-back: status, last sent time and a note per row, in the three `gmerge` columns. A `skip` or `stop` typed into the sheet wins over Handsent. - Templates: name, subject, body (plain text or sanitized HTML with images) and follow-ups. Create a campaign from a template by id. - Variants: steps carry a `variant` number, so the first email can have subject and body variants; the report compares contacted, replied and reply rate per variant. - Reports: recipients, queued, contacted, replied, bounced, unsubscribed, skipped, suppressed, errors, reply rate, sends per step, per-variant comparison, sent today, sent in the last hour, sent today across the inbox, last sent time. - Events: an append-only feed with a monotonic `seq`, types `imported`, `sent`, `replied`, `auto_replied`, `bounced`, `unsubscribed`, `skipped`, `error`, `campaign_started`, `campaign_paused`, `campaign_finished`, `daily_summary`, `test_sent`. Every event can be delivered to a webhook. - Audit log: every action with an actor (a user, an API key, or the system worker) and a dotted action name such as `campaign.start`, `template.update`, `suppression.add`, `list.import`, `api_key.create`, `member.invite`. - Teams: roles owner, admin and member. Everyone in a workspace sees every campaign. Owners and admins manage members, roles, the mailing address, API keys and webhooks. - Safety: a send row is written before the Gmail call and an in-flight guard prevents a duplicate after a crash; two workers can never send the same campaign; an unresolved merge field holds the row instead of sending a blank. ## 3. How to add it to your project ### 3.1 Create an API key Team page, "API keys" tab, "Create key". Give it a name (for example "CRM sync") and a scope: | Scope | Allows | |---|---| | read | GET requests only: lists, contacts, templates, campaigns, reports, events, sends, suppressions, audit log. | | write | Everything in read, plus creating and changing lists, contacts, templates and campaigns, test sends, dry runs, sync, pause, and adding suppressions. | | admin | Everything in write, plus starting or resuming a campaign, removing suppressions, and creating or deleting webhooks. | The key is shown once and starts with `hs_`. Keys belong to the workspace, not to a person, and every action taken with one is recorded in the audit log under the key's name. Only owners and admins can create or delete keys. ### 3.2 Authenticate Send the key on every request as a bearer token: Authorization: Bearer hs_... The header `x-api-key: hs_...` is accepted as well. Base URL: `https://handsent.buildnode.io/api/v1`. Every body and response is JSON. Check the key and its workspace: curl https://handsent.buildnode.io/api/v1/me -H "Authorization: Bearer hs_..." {"organizationId":"...","key":{"id":"...","name":"CRM sync"},"scope":"write"} ### 3.3 Errors Every error has the shape {"error":{"code":"validation_error","message":"...","issues":[{"path":"rows.0.email","message":"..."}]}} Status codes: 400 validation error (with `issues`), 401 missing or invalid key (`unauthenticated`, `invalid_key`), 403 the key's scope is too low (`insufficient_scope`), 404 the resource is not in this workspace, 409 a conflict such as deleting a list a campaign uses. ### 3.4 Pagination - Lists and campaigns: keyset. Pass `limit` (default 50, max 200) and `cursor`; the response has `items` and `nextCursor` (null at the end). - Contacts, campaign contacts, sends, suppressions: `limit` (default 100, max 1000) and `offset`; the response has `items` and `nextOffset` (null at the end). - Events: `since` (a seq, default 0) and `limit` (default 100, max 1000); the response has `items` in ascending seq order and `nextCursor`, a seq to pass back as `since`. Keep the last seq you processed and you have an exactly-once feed. - Audit: newest first by default, ascending when `since` is given; `limit` up to 500; `nextCursor` is a seq. ### 3.5 Webhooks Create one (admin scope): curl -X POST https://handsent.buildnode.io/api/v1/webhooks \ -H "Authorization: Bearer hs_..." -H "Content-Type: application/json" \ -d '{"url":"https://example.com/handsent","eventTypes":["replied","bounced","unsubscribed"]}' `eventTypes` may list any event type, `"*"`, or be empty for all events. The response includes `secret` (64 hex characters) exactly once; store it. Webhook URLs must be public http(s) addresses; private and internal hosts are refused. Each delivery is a POST with headers `Content-Type: application/json`, `Handsent-Event: `, `User-Agent: Handsent-Webhooks/1.0` and Handsent-Signature: t=,v1= where the HMAC key is the secret and the signed string is `.`. The body is {"id":"...","seq":123,"type":"replied","ts":"2026-08-29T17:03:00.000Z","campaignId":"...","email":"ana@example.com","contact":{"email":"ana@example.com","fields":{"first_name":"Ana"},"listId":"..."},"data":{...}} `contact` is null for events without one (for example `campaign_started`). Reply with any 2xx within 10 seconds. Anything else is retried with exponential backoff (30 s, 60 s, 120 s, up to 6 hours between attempts) for up to 10 attempts. Verify in Node: import { createHmac, timingSafeEqual } from "node:crypto"; function verify(secret, header, rawBody, toleranceSeconds = 300) { const parts = Object.fromEntries(header.split(",").map((p) => p.split("="))); const expected = createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex"); const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) <= toleranceSeconds; return fresh && expected.length === parts.v1.length && timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1)); } Use the raw body bytes, not a re-serialized object. `POST /webhooks/{id}/test` queues a delivery of the workspace's most recent event so you can check your endpoint (409 if there are no events yet). ## 4. How to use the API Base URL `https://handsent.buildnode.io/api/v1`; every example assumes `-H "Authorization: Bearer hs_..." -H "Content-Type: application/json"`. Ids are UUIDs. Emails in paths must be URL-encoded. ### 4.1 Create a list and add contacts curl -X POST $BASE/lists -d '{"name":"Directory listings, August","sourceType":"api"}' {"id":"LIST_ID","name":"Directory listings, August","sourceType":"api","columns":[],"watch":false,...,"contacts":{"active":0,"skipped":0,"suppressed":0,"invalid":0,"total":0}} curl -X POST $BASE/lists/LIST_ID/contacts -d '{ "rows": [ {"email":"ana@example.com","fields":{"first_name":"Ana","company":"Northwind","listing_url":"https://example.com/l/123"}}, {"email":"ben@example.com","fields":{"first_name":"Ben","company":"Fabrikam","listing_url":"https://example.com/l/124"}} ]}' {"added":2,"updated":0,"skippedByCell":0,"duplicates":0,"invalid":[]} Rows are upserted by email (max 5000 per call). Field values are strings and become merge fields. Existing contacts keep their status. **`mode`, and the one way this call bites.** An optional `"mode": "merge" | "replace"` decides what happens to fields the request does not mention. Omit it and you get the right default for the list: `merge` for api, csv and manual lists, `replace` for sheet-backed ones, because a sheet row IS the whole row while an API call is a patch. Send `merge` explicitly when you push partial updates. Under `replace`, a field you leave out is set to `""`, and neither outcome is loud: a merge field written `{{x|fallback}}` quietly sends its fallback (so a blanked link mails a link to nowhere), and one written `{{x}}` raises a merge error that HOLDS the contact, which reads as the campaign having stopped on its own. Nothing appears in an error feed either way. curl -X POST $BASE/lists/LIST_ID/contacts -d '{ "mode":"merge", "rows":[{"email":"ana@example.com","fields":{"listing_url":"https://example.com/l/999"}}]}' **Bulk skip: the conversion hook.** When your app learns a batch of people converted, one call takes them out of every campaign on that list: curl -X POST $BASE/lists/LIST_ID/contacts/skip -d '{ "emails":["ana@example.com","ben@example.com"], "reason":"signed up"}' {"requested":2,"changed":2} Addresses that are not on the list are ignored, so you can hand over a whole batch without reconciling first. Pass `"status":"active"` to put people back. Skip, not suppress: suppression is workspace-wide and permanent, and someone who just converted is exactly who you may want to email later. Related calls: `GET /lists`, `GET /lists/{id}`, `DELETE /lists/{id}` (409 if a campaign uses it), `GET /lists/{id}/contacts?status=active&limit=100&offset=0`, `GET /lists/{id}/contacts/{email}`, `PATCH /lists/{id}/contacts/{email}` with `{"status":"skipped"}` or `{"status":"active"}` or `{"fields":{...}}` (this one always merges) and an optional `note`, `DELETE /lists/{id}/contacts/{email}`. ### 4.2 Save a template curl -X POST $BASE/templates -d '{ "name": "Claim your listing", "subject": "{{company}} is now listed", "bodyText": "Hi {{first_name}},\n\nWe added {{company}} to our directory: {{listing_url}}. You can claim the page and correct anything we got wrong.\n\nJorgen\nBuildnode, 2015 Main St, Vancouver, BC\nReply STOP to unsubscribe.", "followUps": [ {"afterDays": 4, "subject": "", "bodyText": "Hi {{first_name}}, in case this slipped by: {{listing_url}}. Reply STOP to unsubscribe. Buildnode, 2015 Main St, Vancouver, BC"} ]}' `bodyHtml` may be sent instead of, or with, `bodyText`; it is sanitized and the plain-text part is derived from it. On PATCH the field you send wins: sending `bodyText` alone replaces the body as plain text and clears any stored rich body, because the alternative is a 200 that silently regenerates the old text from the old html. Send both to change both. Up to 10 follow-ups, each with `afterDays` of at least 1. Related: `GET /templates`, `GET /templates/{id}`, `PATCH /templates/{id}` (only the fields sent change), `DELETE /templates/{id}`. ### 4.3 Create a campaign from the template curl -X POST $BASE/campaigns -d '{ "name": "Listings, August", "inboxId": "INBOX_ID", "listId": "LIST_ID", "templateId": "TEMPLATE_ID", "fromName": "Jorgen at Buildnode", "limits": {"perDay": 60, "perHour": 20, "window": "09:30-16:30", "days": "mon-fri", "timezone": "America/Vancouver"}, "schedule": {"waves": {"size": 50, "everyHours": 24}}, "notifyEmail": "jorgen@example.com" }' The response is the campaign (status `draft`) with its `steps`. Instead of `templateId` you can send `steps` inline: an array of `{position, afterDays, subject, bodyText, bodyHtml?, variant?, gmailDraftSubject?}` where position 0 is the first email. Optional fields: `replyTo`, `cooldownDays` (default 90), `compliance` (`strict` or `off`), `startAt` (ISO 8601 with offset). `inboxId` is the id of a connected Gmail account in the workspace. Get it from `GET /inboxes` (MCP: `list_inboxes`), which returns `{"items":[{"id","email","displayName","status","dailyCap"}]}`. Connecting a NEW inbox is a Google sign-in, so it is the one setup step an API key cannot do for you. Update a draft with `PATCH /campaigns/{id}` (same fields; inbox and list can only change while draft, and the number of steps is fixed after the first start). ### 4.4 Test, dry run, start curl -X POST $BASE/campaigns/CAMPAIGN_ID/test -d '{"to":"me@example.com","contactEmail":"ana@example.com"}' {"subject":"Northwind is now listed","renderedFor":"ana@example.com"} curl -X POST $BASE/campaigns/CAMPAIGN_ID/dry-run -d '{"max":50}' curl -X POST $BASE/campaigns/CAMPAIGN_ID/start -d '{}' Start requires admin scope. It refuses with a 400 if the campaign has never been test-sent or if the lint finds a blocking problem (for example a missing mailing address or unsubscribe line under `strict` compliance); `{"force":true}` overrides both. Start also resumes a paused campaign. ### 4.5 Pause curl -X POST $BASE/campaigns/CAMPAIGN_ID/pause Nothing more is sent until the campaign is started again. Replies and bounces are still tracked. ### 4.6 Read the report curl $BASE/campaigns/CAMPAIGN_ID Returns the campaign, its `steps`, and a `report` with `recipients`, `queued`, `contacted`, `replied`, `autoReplied`, `bounced`, `unsubscribed`, `skipped`, `suppressed`, `errors`, `replyRate`, `byStage`, `byVariant`, `sentToday`, `sentLastHour`, `accountSentToday` and `lastSentAt`. `GET /campaigns` returns every campaign with a shorter `summary`. Who is where: curl "$BASE/campaigns/CAMPAIGN_ID/contacts?status=replied" Statuses: pending, sending, awaiting, done, replied, auto_replied, bounced, unsubscribed, skipped, suppressed, error. `POST /campaigns/{id}/contacts/{email}/reset` clears an error or skip so the person is queued again. `POST /campaigns/{id}/sync` checks the inbox for replies and bounces right now and writes back to the sheet. ### 4.7 Events and sends curl "$BASE/events?since=0&limit=100" {"items":[{"id":"...","seq":1,"type":"campaign_started","campaignId":"...","contactId":null,"email":null,"data":null,"ts":"..."}, ...],"nextCursor":"100"} Filter with `type=` and `campaignId=`. `GET /sends?campaignId=...` lists sent messages newest first, with the Gmail message and thread ids so you can link to the conversation. `GET /audit?action=campaign.` lists who did what. ### 4.8 Suppressions curl -X POST $BASE/suppressions -d '{"emails":["carl@example.com"],"reason":"asked to be removed"}' {"added":1} curl "$BASE/suppressions?limit=100&offset=0" curl -X DELETE $BASE/suppressions -d '{"emails":["carl@example.com"]}' {"removed":1} Adding takes write scope; removing takes admin scope. Suppressed addresses are skipped by every campaign in the workspace, including ones already running. ## 5. How to use the MCP server Handsent's MCP server lets an assistant run the workspace: add contacts, save templates, create a batch, send a test, start, pause, read reports and the activity feed, and manage the do-not-contact list. - Endpoint: `https://handsent.buildnode.io/api/mcp` - Transport: Streamable HTTP, stateless. Every call is a POST with a JSON-RPC body and gets a JSON response; there is no session and no SSE stream (GET and DELETE return 405). - Auth: the same API key as the REST API, sent as `Authorization: Bearer hs_...`. The key's scope applies: a `read` key can call only the read-only tools, `write` covers everything except `resume_campaign` and `remove_suppressions`, which need `admin`. - Server name `handsent`. Read-only tools carry the `readOnlyHint` annotation. ### 5.1 Tools | Tool | What it does | |---|---| | list_lists | Every contact list with counts of active, skipped, suppressed and invalid contacts. Start here to find a list id. | | get_list | One list: name, source (sheet, csv, api), columns, last sync time, contact counts, and the campaigns that send from it with `activeCampaigns`. Check that last number if pushes seem to go nowhere. | | add_contacts | Add people to a list or update their fields (`listId`, `rows: [{email, fields}]`, max 5000, optional `mode`). | | skip_contact | Stop emailing one person on a list (`listId`, `email`, optional `reason`). | | skip_contacts | Stop emailing many people at once (`listId`, `emails`, optional `status` and `reason`). The conversion hook for an integrated app. Unknown addresses are ignored. | | unskip_contact | Make a skipped contact active again. | | create_list | Create an empty API list to push contacts into (`name`). Use once when wiring an integration. | | list_inboxes | The Gmail inboxes the workspace can send from. You need an `id` from here to create a campaign. | | list_campaigns | Every campaign with status and a funnel: recipients, queued, contacted, replied, bounced, reply rate. | | campaign_report | Full report for one campaign: funnel, reply rate, sends per step, variant comparison, sent today, last sent. | | campaign_contacts | People in a campaign and their state; filter by `status` to answer "who replied?". | | pause_campaign | Pause a running campaign. Replies and bounces are still tracked. | | resume_campaign | Start or resume a campaign within its limits and window. Refuses if it was never test-sent or has template problems. Admin scope. | | send_test | Send a test copy of the first email to an address you choose, rendered with a real contact's fields (`campaignId`, `to`, optional `contactEmail`). | | dry_run | Simulate the next batch without sending: who would be emailed, who would be skipped and why (`campaignId`, `max`). | | recent_events | The activity feed, oldest first: sends, replies, bounces, unsubscribes, campaign start and pause, errors. Pass the last `seq` you saw as `since`. | | add_suppressions | Add addresses to the workspace do-not-contact list (`emails`, `reason`). | | remove_suppressions | Remove addresses from the do-not-contact list. Admin scope. | | list_templates | Saved templates with subject, body and follow-ups. | | save_template | Save a reusable template: `name`, `subject`, `bodyText`, optional `bodyHtml`, optional `followUps` of `{afterDays, subject, bodyText}`. | | create_batch | Create a draft campaign from `templateId`, `listId` and `inboxId` (plus optional `name`, `fromName`, `replyTo`, `limits`, `cooldownDays`, `compliance`, `notifyEmail`, `startAt`, `schedule`). Nothing is sent until send_test and resume_campaign. | | audit_log | Who did what and when, newest first; `since`, `limit`, and an `action` prefix filter such as `campaign.`. | A typical agent session: `list_inboxes` and `list_lists` to find the inbox and list, `add_contacts` to add rows, `list_templates` or `save_template`, `create_batch`, `send_test` to the operator's own address, `dry_run` to show who is next, `resume_campaign` to start, then `campaign_report` and `recent_events` to follow along, and `pause_campaign` or `add_suppressions` if anything needs to stop. ### 5.2 Claude Code claude mcp add --transport http handsent https://handsent.buildnode.io/api/mcp --header "Authorization: Bearer hs_..." Or in a project's `.mcp.json`: { "mcpServers": { "handsent": { "type": "http", "url": "https://handsent.buildnode.io/api/mcp", "headers": { "Authorization": "Bearer hs_..." } } } } ### 5.3 Claude Desktop Claude Desktop's `claude_desktop_config.json` launches local servers, so bridge the remote endpoint with `mcp-remote`: { "mcpServers": { "handsent": { "command": "npx", "args": ["-y", "mcp-remote", "https://handsent.buildnode.io/api/mcp", "--header", "Authorization: Bearer hs_..."] } } } ### 5.4 Cursor In `.cursor/mcp.json` (project) or `~/.cursor/mcp.json` (global): { "mcpServers": { "handsent": { "url": "https://handsent.buildnode.io/api/mcp", "headers": { "Authorization": "Bearer hs_..." } } } } ### 5.5 Any other client Any MCP client that speaks Streamable HTTP and can send a static header works the same way: URL `https://handsent.buildnode.io/api/mcp`, header `Authorization: Bearer hs_...`. A key with `read` scope is enough for an assistant that only reports. ## 6. Wiring an app into Handsent The recipe below is the shape every app integration has taken so far. It is written as a checklist because the failure modes are all in the seams, not in the calls. ### 6.1 The division of labour Your app owns **who** and **when**: identity, the event that makes someone worth emailing, and the fact that they later converted. Handsent owns **the email**: copy, pacing, the threaded follow-up, reply and bounce detection, suppression, cooldown and the commercial-email footer. The line matters because of who edits what. Copy belongs in Handsent, where the person responsible for the words can change them without a deploy of your app. If you find yourself writing subject lines in your codebase, the integration is upside down. Your app should push facts (a name, a link, a city) and nothing that reads like a sentence. ### 6.2 Setup, once 1. A person signs in with Google and connects the sending inbox. This is the only step an API key cannot do: it is an OAuth consent. 2. Set the workspace mailing address (Team page). Campaigns will not start without it, so doing this late blocks the last step instead of the first. 3. `POST /lists {"name":"...","sourceType":"api"}`: one list per sequence, not one per campaign. Follow-ups live inside a campaign, so "intro + reminder" is ONE campaign on ONE list. 4. `GET /inboxes` for the `inboxId`. 5. Create the campaign as a draft and leave it stopped. A person sends a test and starts it. Do not have your app call `/start`: the first real send is a judgement call about copy, and it should stay one. Record the list id and the API key in your app's config the way you would any other credential. Everything after this is two calls. ### 6.3 Push on the event When your app learns someone is worth emailing, upsert them: POST /lists/{listId}/contacts {"mode":"merge","rows":[{"email":"...","fields":{...}}]} Four things to get right: - **Push the full field set**, not just what changed. It is idempotent and it is immune to the blanking trap in 4.1. - **Lowercase the address.** Contacts are keyed by address; a mixed-case push creates a twin that gets its own copy of every email. - **Collapse duplicates before you push.** One address often fronts several records on your side (a head office, a shared inbox). Handsent keys by address, so the last row silently wins. Decide which row wins yourself. - **Keep a watermark on your side.** Handsent will not re-send to a contact it has already emailed, but your app still needs to know it has pushed, or it will rebuild the same batch on every tick. A nullable `pushed_at` on the row is enough. Stamp it AFTER a successful push: the push is idempotent, so a retry is free, while stamping first turns one failed call into a person nobody contacts. ### 6.4 Skip on conversion When they sign up, reply out of band, or otherwise stop being a prospect: POST /lists/{listId}/contacts/skip {"emails":[...],"reason":"signed up"} Record the address you actually pushed, rather than recomputing it later. The "best" address for a record often changes at exactly the moment they convert (a signup introduces an account email that outranks the directory one), and a recomputed address will miss the contact you are trying to stop. Handsent already stops a sequence when someone replies, bounces or unsubscribes. This call is for conversions it cannot see, which happen in your product. ### 6.5 Merge-field discipline Write every optional field as `{{field|fallback}}` in the template. Handsent substitutes the fallback when the value is blank or absent, so a fallback turns a missing field into a slightly-less-personal email instead of a held contact. Reserve bare `{{field}}` for fields that must never be wrong: a link, an amount, a name in the subject line. There, holding the contact is the behaviour you want. Greetings are the common case and belong in the template: `Hi {{first_name|there}}` is better than your app inventing the string "there", because it keeps the word in the place the copywriter can reach. ### 6.6 Check that the list is wired to a campaign The most expensive way an integration fails is the quietest: you push contacts into a list that no running campaign sends from. Every call returns 200, the contact count climbs, and nobody is emailed. It looks exactly like success. `GET /lists/{id}` (MCP: `get_list`) reports this: {"id":"...","name":"...","contacts":{"active":42,...}, "campaigns":[{"id":"...","name":"Claim your posting","status":"draft"}], "activeCampaigns":0} `activeCampaigns` counts campaigns in status `active`, the only status that sends. Assert it is at least 1 in whatever health check your app already has; `campaigns` tells you which one to go and start when it is 0. A campaign whose `startAt` is still in the future counts as active, because it is wired up and the worker is holding it on purpose. ### 6.7 Watching it work `GET /events?since=` is an ordered feed of sends, replies, bounces, unsubscribes and errors; keep the last `seq` and poll it, or register a webhook (3.5) and skip the polling. `GET /campaigns/{id}` gives the funnel. `POST /campaigns/{id}/dry-run` answers "who is next and who is being skipped, and why" without sending anything, which is the single most useful call when an integration looks stuck. If sending has stopped and nothing looks wrong: check for held contacts (`GET /lists/{id}/contacts` and look for blank fields), check the send window and weekdays, and check whether the daily cap is already spent. ## Reference: field values - Campaign status: draft, active, paused, finished. - Contact status (list): active, skipped, suppressed, invalid. - Campaign contact status: pending, sending, awaiting, done, replied, auto_replied, bounced, unsubscribed, skipped, suppressed, error. - Event types: imported, sent, replied, auto_replied, bounced, unsubscribed, skipped, error, campaign_started, campaign_paused, campaign_finished, daily_summary, test_sent. - Limits: perDay (1 to 2000), perHour (1 to 500), gapSeconds [min, max], window "HH:MM-HH:MM", days like "mon-fri", timezone (IANA). - Waves: size (1 or more), everyHours (1 or more). - Audit actor types: user, api_key, system. ## Company Handsent is made by Buildnode Inc., 2015 Main St, Vancouver, BC, Canada. hello@buildnode.io.