Developers

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, a client_secret and a webhook_secret once; 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.com with Authorization: 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=S256

2. 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

ScopeGrants
mail.sendSend tracked email as the user from their Gmail.
mail.read_threadsRead the conversations your app started (never any other mail).
eventsReceive webhooks: sent, open, click, reply, bounce, unsubscribe, document_view, signed, signature_canceled.
documentsUpload PDFs and get tracked links.
signaturesRequest e-signatures.
calendar.invitesCreate 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 pathScopePurpose
GET /api/partner/v1/meanyMailbox, scopes and today's quota
POST /api/partner/v1/sendmail.sendSend a tracked email
GET /api/partner/v1/threads/{thread_id}mail.read_threadsRead a thread your app started
POST /api/partner/v1/documentsdocumentsUpload a PDF (multipart file), get a tracked link
POST /api/partner/v1/signature-requestssignaturesRequest a signature (multipart file, or JSON document_id)
POST /api/partner/v1/signature-requests/{request_id}/cancelsignaturesWithdraw a signature request your app created
GET /api/partner/v1/calendar/freebusycalendar.invitesBusy times on the user's primary calendar
POST /api/partner/v1/calendar/invitescalendar.invitesCreate an event and invite attendees
POST /api/partner/v1/webhooks/testclient authSend yourself a signed test event
GET /api/partner/v1/webhooks/deliveriesclient authWebhook delivery log
POST /api/partner/v1/webhooks/events/{id}/retryclient authRe-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_at the 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_id your app received.
  • Passthrough headers: List-Unsubscribe, List-Unsubscribe-Post (exactly List-Unsubscribe=One-Click, with an https unsubscribe URL), List-Id and X-*. 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_key with an invite. A retry with the same key and body returns the original event; the same key with a different body is refused with 422 idempotency_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)));
}
TypeWhen
sentA recipient's copy was accepted by Gmail.
openA human opened it (scanners, Apple Mail prefetch and the sender's own views are filtered). Every re-open fires.
clickA tracked link was clicked; includes url.
replyA recipient replied in the thread; reply.text and reply.html need mail.read_threads.
bounceThe address bounced (it is suppressed for future sends).
unsubscribeThe recipient unsubscribed through Fast Mail Tracker.
document_viewSomeone opened a tracked document your app uploaded.
signedA signature request your app created was signed.
signature_canceledYour app withdrew a signature request; its link no longer accepts a signature.
failedA copy could not be sent; includes reason.
suppressedNot 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/test carry "test": true: never count them.
  • Ignore event types you do not know; new ones are added without notice.

Rate limits and quotas

LimitValueOn exceed
Requests per app20 per second (burst 60)429 rate_limited + Retry-After
Requests per connected user5 per second (burst 20)429 rate_limited
Token and webhook-admin endpoints5 per second per IP (burst 50)429
Recipients per day, personal Gmail500 (Gmail's limit)429 daily_quota_exceeded
Recipients per day, Google Workspace2,000 (Gmail's limit)429 daily_quota_exceeded
Recipients per day, your app capSet 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 ..." } }
StatusCodes
400invalid_request
401invalid_token (expired, revoked, or the user disconnected your app)
403insufficient_scope, plan_required
404thread_not_found, document_not_found, event_not_found
409gmail_not_connected, calendar_not_connected, idempotency_in_progress
422idempotency_key_reused
429rate_limited, daily_quota_exceeded
501 / 502calendar_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.