Fast Mail Tracker Partner API
Let your users send tracked email from their own Gmail, share tracked PDFs and collect signatures from your app, and get every open, click and reply as a signed webhook.
Getting started · Authorization · Scopes · Endpoints · Sending email · Calendar · Webhooks · Rate limits and quotas · Errors · OpenAPI 3.1 spec
Getting started
- Register your app. Email support@fastmailtracker.com with your app name, exact redirect URIs (https), the scopes you need, your webhook URL (https), and your homepage and privacy policy links. You receive a
client_id, aclient_secretand awebhook_secretonce; store them in a secrets manager. - Connect a user with OAuth 2.0 (authorization code + PKCE). You get an access token, a refresh token, the Gmail address mail goes out from, and whether it is a Google Workspace or personal mailbox.
- Call the API at
https://api.fastmailtracker.comwithAuthorization: Bearer <access_token>.
Everything your app does is limited to what the user approved, shown to them in plain words, and recorded in an audit log. Users can disconnect your app any time in Settings, Account, Connected apps.
Authorization
1. Send the user to the consent screen. Generate a random code_verifier (43-128 characters) and its S256 code_challenge, and a random state.
https://www.fastmailtracker.com/partner/connect
?response_type=code
&client_id=fmtc_...
&redirect_uri=https%3A%2F%2Fyourapp.com%2Foauth%2Ffmt%2Fcallback
&scope=mail.send%20events%20mail.read_threads
&state=<random>
&code_challenge=<base64url(sha256(code_verifier))>
&code_challenge_method=S2562. Handle the redirect. On approval the browser returns to your redirect_uri with code, state and iss. Check that state matches and iss is https://api.fastmailtracker.com. On cancel you get error=access_denied. Codes are single use and expire in 10 minutes.
3. Exchange the code from your server (never from a browser):
curl https://api.fastmailtracker.com/api/partner/v1/token \
-u "$FMT_CLIENT_ID:$FMT_CLIENT_SECRET" \
-d grant_type=authorization_code \
-d code=fmtcode_... \
-d redirect_uri=https://yourapp.com/oauth/fmt/callback \
-d code_verifier=$VERIFIER
{
"access_token": "fmtat_...", "token_type": "Bearer", "expires_in": 3600,
"refresh_token": "fmtrt_...", "scope": "events mail.read_threads mail.send",
"connection_id": "pcon_...", "email": "jane@acme.com", "mailbox_type": "workspace"
}4. Refresh with grant_type=refresh_token. Every refresh returns a NEW refresh token and retires the old one: store the new one atomically. Presenting a retired refresh token again (after a 30-second retry grace) is treated as theft and revokes every token for that user; they must reconnect. Refresh tokens last 180 days from their last use.
5. Disconnect with POST /api/partner/v1/revoke and token=<refresh_token> (RFC 7009).
Scopes
| Scope | Grants |
|---|---|
mail.send | Send tracked email as the user from their Gmail. |
mail.read_threads | Read the conversations your app started (never any other mail). |
events | Receive webhooks: sent, open, click, reply, bounce, unsubscribe, document_view, signed, signature_canceled. |
documents | Upload PDFs and get tracked links. |
signatures | Request e-signatures. |
calendar.invites | Create Google Calendar events, email invitations, and see when the user is busy (times only). Coming soon. |
Request only what you use. A user re-approving with fewer scopes narrows every token immediately.
Endpoints
| Method and path | Scope | Purpose |
|---|---|---|
GET /api/partner/v1/me | any | Mailbox, scopes and today's quota |
POST /api/partner/v1/send | mail.send | Send a tracked email |
GET /api/partner/v1/threads/{thread_id} | mail.read_threads | Read a thread your app started |
POST /api/partner/v1/documents | documents | Upload a PDF (multipart file), get a tracked link |
POST /api/partner/v1/signature-requests | signatures | Request a signature (multipart file, or JSON document_id) |
POST /api/partner/v1/signature-requests/{request_id}/cancel | signatures | Withdraw a signature request your app created |
GET /api/partner/v1/calendar/freebusy | calendar.invites | Busy times on the user's primary calendar |
POST /api/partner/v1/calendar/invites | calendar.invites | Create an event and invite attendees |
POST /api/partner/v1/webhooks/test | client auth | Send yourself a signed test event |
GET /api/partner/v1/webhooks/deliveries | client auth | Webhook delivery log |
POST /api/partner/v1/webhooks/events/{id}/retry | client auth | Re-deliver one event |
Every POST accepts an Idempotency-Key header (send and calendar invites also take idempotency_key in the body; send requires one). A retry with the same key and body returns the original response with Idempotent-Replayed: true; the same key with a different body is refused with 422 idempotency_key_reused; 409 idempotency_in_progress means a request with the same key is still running. Keys are kept 24 hours per app and user.
Sending email
curl https://api.fastmailtracker.com/api/partner/v1/send \
-H "Authorization: Bearer fmtat_..." -H "Content-Type: application/json" \
-d '{
"idempotency_key": "outreach-42-step-1",
"to": [{"email": "sam@prospect.com", "name": "Sam"}],
"from_name": "Olivia from Acme",
"subject": "Quick question",
"html": "<p>Hi Sam, ...</p>",
"text": "Hi Sam, ...",
"headers": {
"List-Unsubscribe": "<https://yourapp.com/u/abc>, <mailto:unsub@yourapp.com>",
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click"
},
"track": {"opens": true, "clicks": true},
"send_at": "2026-11-02T09:30:00Z"
}'
{ "message_id": "em_...", "thread_id": "pth_...", "status": "scheduled",
"send_at": "2026-11-02T09:30:00Z",
"recipients": [{ "email": "sam@prospect.com", "role": "to", "recipient_id": "rcp_..." }] }- Mail leaves through the user's own Gmail, so it is in their Sent folder and passes SPF, DKIM and DMARC as them.
- Each recipient gets their own copy with their own open pixel and click links, while seeing the full To and Cc list. 1-50 recipients per send.
- HTML is sanitized (scripts, forms and event handlers are removed). Without
send_atthe message is queued and leaves within seconds. from_name(optional) sets the display name on the From line, e.g."Olivia from Acme" <user@acme.com>. The address is always the user's connected Gmail. 1-80 characters, one line, no quotes, angle brackets or email address. Omit it to use the mailbox's own name.- To reply in the same conversation, pass the
thread_idyour app received. - Passthrough headers:
List-Unsubscribe,List-Unsubscribe-Post(exactlyList-Unsubscribe=One-Click, with an https unsubscribe URL),List-IdandX-*. Anything else is refused.
Calendar
Coming soon: these answer 501 calendar_not_enabled until Google verifies Fast Mail Tracker's Calendar access. Both need calendar.invites, and the user allows calendar access for their Gmail on the consent screen.
curl "https://api.fastmailtracker.com/api/partner/v1/calendar/freebusy?start=2026-11-02T09:00:00Z&end=2026-11-02T18:00:00Z" \
-H "Authorization: Bearer fmtat_..."
{ "start": "2026-11-02T09:00:00Z", "end": "2026-11-02T18:00:00Z",
"busy": [{ "start": "2026-11-02T10:00:00Z", "end": "2026-11-02T11:30:00Z" }] }
curl https://api.fastmailtracker.com/api/partner/v1/calendar/invites \
-H "Authorization: Bearer fmtat_..." -H "Content-Type: application/json" \
-d '{
"idempotency_key": "demo-42",
"title": "Intro call",
"start": "2026-11-02T12:00:00Z", "end": "2026-11-02T12:30:00Z",
"attendees": [{"email": "sam@prospect.com", "name": "Sam"}],
"meet": true
}'
{ "event_id": "fmt...", "html_link": "https://calendar.google.com/...", "meet_url": "https://meet.google.com/..." }- Free/busy returns busy blocks on the user's primary calendar only: sorted, merged, clipped to your window, in UTC. Never titles, attendees or any other event detail. Window up to 31 days, from 24 hours ago to one year ahead.
- Google emails every attendee, so always send an
idempotency_keywith an invite. A retry with the same key and body returns the original event; the same key with a different body is refused with 422idempotency_key_reused.
Webhooks
Events are POSTed as JSON to your webhook URL, signed with your webhook_secret:
X-FMT-Signature: t=1793050000,v1=5d41402abc4b2a76b9719d911017c592...
X-FMT-Event-Id: evt_...
X-FMT-Event-Type: reply
{ "id": "evt_...", "type": "reply", "message_id": "em_...", "thread_id": "pth_...",
"connection_id": "pcon_...", "recipient": "sam@prospect.com", "at": "2026-11-02T10:14:03Z",
"reply": { "from": "sam@prospect.com", "text": "Sounds good", "html": "<p>Sounds good</p>" } }Verify every request over the raw body before parsing it:
import crypto from "node:crypto";
export function verifyFmtSignature(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false; // replay window
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return header.split(",").filter((p) => p.startsWith("v1="))
.some((p) => crypto.timingSafeEqual(Buffer.from(p.slice(3)), Buffer.from(expected)));
}| Type | When |
|---|---|
sent | A recipient's copy was accepted by Gmail. |
open | A human opened it (scanners, Apple Mail prefetch and the sender's own views are filtered). Every re-open fires. |
click | A tracked link was clicked; includes url. |
reply | A recipient replied in the thread; reply.text and reply.html need mail.read_threads. |
bounce | The address bounced (it is suppressed for future sends). |
unsubscribe | The recipient unsubscribed through Fast Mail Tracker. |
document_view | Someone opened a tracked document your app uploaded. |
signed | A signature request your app created was signed. |
signature_canceled | Your app withdrew a signature request; its link no longer accepts a signature. |
failed | A copy could not be sent; includes reason. |
suppressed | Not sent because the recipient previously opted out or bounced. |
- Answer any 2xx within 10 seconds. Delivery is at least once: dedupe on
id. - Failed deliveries retry with backoff (30s, 2m, 10m, 30m, 1h, 2h, 4h, 8h, 16h), then stop. Redirects are not followed. See every attempt in the delivery log and re-send with the retry endpoint.
- Test events from
POST /api/partner/v1/webhooks/testcarry"test": true: never count them. - Ignore event types you do not know; new ones are added without notice.
Rate limits and quotas
| Limit | Value | On exceed |
|---|---|---|
| Requests per app | 20 per second (burst 60) | 429 rate_limited + Retry-After |
| Requests per connected user | 5 per second (burst 20) | 429 rate_limited |
| Token and webhook-admin endpoints | 5 per second per IP (burst 50) | 429 |
| Recipients per day, personal Gmail | 500 (Gmail's limit) | 429 daily_quota_exceeded |
| Recipients per day, Google Workspace | 2,000 (Gmail's limit) | 429 daily_quota_exceeded |
| Recipients per day, your app cap | Set at registration (optional) | 429 daily_quota_exceeded |
Daily quotas reset at 00:00 UTC and count every recipient (to, cc and bcc). GET /me shows the limit and today's usage. The user's Fast Mail Tracker plan still applies: a gated feature answers 403 plan_required with the plan that unlocks it.
Errors
HTTP/1.1 409 Conflict
{ "error": { "code": "gmail_not_connected", "message": "the user's Gmail is not connected ..." } }| Status | Codes |
|---|---|
| 400 | invalid_request |
| 401 | invalid_token (expired, revoked, or the user disconnected your app) |
| 403 | insufficient_scope, plan_required |
| 404 | thread_not_found, document_not_found, event_not_found |
| 409 | gmail_not_connected, calendar_not_connected, idempotency_in_progress |
| 422 | idempotency_key_reused |
| 429 | rate_limited, daily_quota_exceeded |
| 501 / 502 | calendar_not_enabled, gmail_unavailable, google_unavailable |
The token and revoke endpoints use the OAuth shape {"error": "invalid_grant", "error_description": "..."}. Codes are stable; messages are for people and may change.