<!-- Generated from docs/API.md by scripts/build-agent-docs.mjs. Do not edit. -->

# GTM Swag API and MCP reference

GTM Swag helps B2B go-to-market teams in the US get meetings by sending personalized, thoughtful physical gifts: an AI agent or workflow tool picks a gift that fits each person and the moment, an owner or admin on the team approves the spend, and Better Swag produces and ships each gift. Agents prepare; people approve. This page lists every customer route and tool. For the task-by-task walkthrough, read [the agent guide](https://gtmswag.com/agent-guide.md).

**Status.** `GET /api/v1/workflow` (MCP `get_workflow_guide`) returns the workspace's `mode`. With `mode: "test"`, which every workspace has until GTM Swag enables delivery for it, everything works end to end but no gift is produced or shipped and payments run in Stripe's test environment; don't send those gift links to real prospects. To enable delivery, a person on the team emails eric@betterswag.com (https://gtmswag.com/support). With `mode: "live"`, gifts ship, only gifts with `verified: true` can be ordered (`409 PRODUCT_UNVERIFIED`), and each gift needs a US address supplied by the sender (`addressMode: "known"`): a person added with `addressMode: "confirm"` shows the queue blocker "Needs a shipping address" and approving them returns `409 LIVE_ADDRESSLESS_UNAVAILABLE`. Only a batch or single campaign whose preview returns a `prepaidOffer` lets recipients confirm their own address; playbooks never do. The agent guide's "Status" explains what changes when delivery is turned on.

- REST base: `https://gtmswag.com/api/v1`. Send `Authorization: Bearer YOUR_GTM_SWAG_KEY` on every request and `Content-Type: application/json` on JSON writes. Success is `{"data": ...}`; errors use the envelope under "Errors and retries".
- MCP: `https://gtmswag.com/mcp` (OAuth sign-in or a workspace key). ChatGPT uses `https://gtmswag.com/chatgpt/mcp`.
- Money is integer USD cents. Times are ISO 8601 unless stated.
- Test access with `GET /api/v1/campaigns`: a `200` with a `data` list, even an empty one, confirms the key works. The public catalog doesn't need a key, so it proves nothing.
- Machine-readable: [OpenAPI 3.1](https://gtmswag.com/openapi.json), [llms.txt](https://gtmswag.com/llms.txt), [llms-full.txt](https://gtmswag.com/llms-full.txt) and the [MCP server card](https://gtmswag.com/.well-known/mcp.json). Connection steps: [connection guide](https://gtmswag.com/guides/connect.md).

## Playbook-first loop

Use playbooks first (`mode: "always_on"`, one person per trigger), then batches for a fixed list, then single gifts.

| Step | MCP tool | REST |
| --- | --- | --- |
| 1. Choose a gift | `search_gifts`, then `get_gift` | `GET /catalog` |
| 2. Create the playbook as a draft | `create_campaign` with `mode: "always_on"` | `POST /campaigns` |
| 3. A person turns it on | Give the user `https://gtmswag.com/campaigns/CAMPAIGN_ID?review=1` | Same |
| 4. Add one person per trigger | `add_to_playbook` | `POST /playbooks/CAMPAIGN_ID/people` |
| 5. Hand waiting gifts to a person | `list_queue`, then give the user each `approvalUrl` | `GET /queue` |
| 6. Keep the balance funded | `get_balance`, then give the user `topUpUrl` when `lowBalance` is true | `GET /balance` |
| 7. Share gift links | `get_gift_link_records` or `export_gift_links` | `GET /campaigns/CAMPAIGN_ID/distribution` |
| 8. Track | `list_tracking_events` | `GET /events` |

Agents never approve, skip, pay or add funds. An owner or admin can turn on a playbook's auto-approve in the web app; gifts within its limits are then approved and paid from the balance.

## Who can do what

A workspace has one owner, any number of admins and members. An API key acts with its creator's current role, but never does anything marked "signed-in only". A sign-in connection (OAuth) acts within the scopes the user granted. "Signed-in" means a person using gtmswag.com in a browser.

| Action | Member | Admin | Owner | API key | Sign-in connection |
| --- | --- | --- | --- | --- | --- |
| Read catalog, campaigns, gifts and tracking | Yes | Yes | Yes | Yes | `gifts:read` |
| Create and edit drafts | Yes | Yes | Yes | Yes | `gifts:write` (drafts only) |
| Add people (`add_to_playbook`, `add_recipients`) | Yes | Yes | Yes | Yes | `gifts:write` |
| Pause or complete a campaign | Yes | Yes | Yes | Yes | No |
| Turn a playbook on, or activate any campaign | Yes, signed-in | Yes, signed-in | Yes, signed-in | No | No |
| Approve, skip or unskip a waiting gift | No | Yes, signed-in | Yes, signed-in | No | No |
| Turn on auto-approve, or raise its limits or the monthly budget while it is on | No | Yes, signed-in | Yes, signed-in | No | No |
| Lower auto-approve limits or turn it off | Yes, signed-in | Yes, signed-in | Yes, signed-in | No | No |
| Read the queue and balance | Yes | Yes | Yes | Yes | `gifts:read`, MCP only |
| Add funds to the balance | No | Yes, signed-in | Yes, signed-in | No | No |
| Pay a campaign checkout, or request a refund | No | Yes, signed-in | Yes, signed-in | No | No |
| Read gift links and CSV exports | Yes | Yes | Yes | Yes | `gifts:links:read` |
| Create an API key | Yes, signed-in | Yes, signed-in | Yes, signed-in | No | No |
| Create or remove a webhook | No | Yes, signed-in | Yes, signed-in | No | No |
| List webhooks; read and retry deliveries | No | Yes | Yes | Owner or admin key | No |
| Update workspace settings | No | Yes | Yes | Owner or admin key | No |
| List teammates | Yes | Yes | Yes | Yes | No |
| Invite or remove teammates | No | Members only, signed-in | Yes, signed-in | No | No |
| Change a teammate's role | No | No | Yes, signed-in | No | No |

Refusals: a key gets `403 HUMAN_APPROVAL_REQUIRED` for a signed-in-only action; a member gets `403 TEAM_MANAGER_REQUIRED` for an owner or admin action; a connection gets `403 INSUFFICIENT_SCOPE` outside its scopes. A sign-in connection reaches REST only on the gift-link routes (`/api/v1/campaigns/CAMPAIGN_ID/distribution…` and `/api/v1/distribution/exports/…`); every other REST route returns `401 AUTH_REQUIRED`, so it uses MCP tools instead. A ChatGPT connection has its own smaller tool set, with no playbook, queue or balance tools. The only spending that happens without a person is auto-approve, within limits an owner or admin set.

## Errors and retries

REST errors use a non-2xx status. MCP tool errors return HTTP 200 with `result.isError: true`, so check it on every result; refusals before a tool runs (`401`, an OAuth `403` or `429`) keep their HTTP status. All carry the same body:

```json
{
  "error": "Turn this playbook on to add people.",
  "code": "PLAYBOOK_OFF",
  "nextAction": "open_url",
  "url": "https://gtmswag.com/campaigns/CAMPAIGN_ID?review=1",
  "retryable": false
}
```

Follow `nextAction`: `open_url` (give the user `url`), `reread_and_retry` (read current state, then retry), `ask_user` (get missing or corrected input), `retry_later` (wait `retryAfterMs`, then send the same request) or `stop`. `retryable` is true only when retrying can succeed without a person.

| Code | HTTP | `nextAction` | Meaning and what to do |
| --- | --- | --- | --- |
| `AUTH_REQUIRED` | 401 | `open_url` | The key or connection is missing, revoked or expired. Ask the user to reconnect; don't retry. |
| `INSUFFICIENT_SCOPE` | 403 | `ask_user` | The connection lacks the scope for this tool. Ask the user to reconnect and grant it. |
| `HUMAN_APPROVAL_REQUIRED` | 403 | `open_url` | Only a signed-in person can do this. Give the user `url` (campaign review or queue) and stop. |
| `TEAM_MANAGER_REQUIRED` | 403 | `stop` | Only an owner or admin can do this. Tell the user. |
| `DEMO_LIMITED` | 403 | `stop` | A demo workspace can't use keys, webhooks, uploads or imports. The user creates an account at https://gtmswag.com/login. |
| `PLAYBOOK_OFF` | 409 | `open_url` | The playbook is a draft or paused. A signed-in teammate turns it on at `url`; then send the same add again. |
| `PLAYBOOK_BUSY` | 409 | `retry_later` | Other people were being added to the same playbook. Nothing was saved. Wait `retryAfterMs` (250–999 ms) and send the same request. |
| `NOT_ALWAYS_ON` | 409 | `stop` | The campaign is a batch or single gift. Use `add_recipients`, or a playbook. |
| `RECIPIENT_SKIPPED` | 409 | `open_url` | An owner or admin skipped this recipient. Don't re-add them. |
| `APPROVAL_REQUIRED` | 409 | `open_url` | Playbook gifts are created only through approval. Give the user `url` for an owner or admin. |
| `INSUFFICIENT_BALANCE` | 402 | `open_url` | The balance can't cover the gift. Give the user `url` (the top-up page). |
| `BUDGET_CAP` | 409 | `open_url` | The playbook's monthly budget is used up. An owner or admin raises it at `url`, or it waits for next month. |
| `MONTHLY_BUDGET_REQUIRED` | 400 | `ask_user` | Auto-approve needs a monthly budget on the playbook. |
| `BALANCE_FUNDED` | 409 | `stop` | The gift was paid from the balance and needs no checkout. |
| `BALANCE_MISMATCH` | 409 | `stop` | A balance-paid order's amount changed in review. It waits for GTM Swag staff; the balance is never charged more. |
| `LIVE_ADDRESSLESS_UNAVAILABLE` | 409 | `ask_user` | With `mode: "live"`, this gift needs a US address from the sender. Keep the draft; with the user's authorization add it as `addressMode: "known"`, or stop. |
| `PRODUCT_UNVERIFIED` | 409 | `stop` | With `mode: "live"`, the gift isn't verified yet, so it can't be ordered. Choose a gift with `verified: true`, or wait for GTM Swag to verify it. |
| `POLICY_CHANGED` | 409 | `reread_and_retry` | `expectedPolicyVersion` is stale. Read the campaign, then retry only what is still missing. |
| `IDEMPOTENCY_CONFLICT` | 409 | `ask_user` | The key was already used with different content. Recover the original request; don't rotate the key to retry. |
| `RECIPIENT_IDENTITY_CONFLICT` | 409 | `stop` | The IDs you sent (email, profile, source record) belong to different people. Ask the user which record is right. |
| `CHECKOUT_LOCKED` | 409 | `retry_later` | An open or paid checkout locks the campaign. Keep the people in your source and retry later. |
| `MODE_LOCKED` | 409 | `stop` | A campaign paid at checkout can't become a playbook. Create a new playbook. |
| `CAMPAIGN_COMPLETED` | 409 | `stop` | The campaign is completed; it takes no changes. |
| `VALIDATION_ERROR` | 400 | `ask_user` | `error` names the invalid fields. Fix them from the schema and catalog; never truncate a note. |
| `RECIPIENT_MESSAGE_REQUIRED` | 400 | `ask_user` | The note is missing, too long, or uses a placeholder this person lacks. |
| `NOT_FOUND` | 404 | `ask_user` | The ID doesn't exist in this workspace. Ask which record was meant. |
| `CAPABILITY_RETIRED` | 410 | `stop` | Prospect invitation email and reminders are retired. Return a gift link or CSV for the customer's own outreach. |
| `RATE_LIMIT` | 429 | `retry_later` | Too many requests. Wait `retryAfterMs`, then retry. |
| `INTERNAL_ERROR` | 500 | `stop` | Unexpected failure. After a write, read current state before trying again. |

Any other 5xx on a read returns `retry_later`; after a write it returns `reread_and_retry`, because the write may have partly landed.

**Rate limits.** Each workspace has 240 requests per minute, shared by all its keys, connections and the web app. Every REST request and every MCP HTTP request counts once; artwork uploads and downloads have their own limits. Windows are fixed minutes on the clock. On `429 RATE_LIMIT`, wait `retryAfterMs` (the time left in the current window) and retry; the response also carries a standard `Retry-After` header in whole seconds, which workflow tools such as HubSpot honor on their own. Daily upload limits reset at UTC midnight, and `retryAfterMs` says when. ChatGPT tool calls have their own limit of 120 per minute per workspace. Send people one request at a time per playbook, or a list with `add_recipients`, instead of parallel row writes.

**Timeouts and lost responses.** `add_to_playbook` is safe to resend with the same `sourceSystem` and `externalId`. Resend `create_campaign` with the same `idempotencyKey` and identical body. For any other write, read current state first; payment and approval retries belong to a person.

## Glossary

| Term | Meaning |
| --- | --- |
| Playbook | A campaign with `mode: "always_on"` that gifts one person per trigger |
| Campaign `mode` | `always_on` (playbook), `batch` (a fixed list) or `single` (one person) |
| Workspace `mode` | `test` or `live`, from `get_workflow_guide`; see "Status" |
| Gift `mode` | The `mode` copied onto a gift, record or event when it was created; it never changes |
| Balance `mode` | `get_balance` reports the balance for the workspace's current `mode`; money added in one never pays for the other |
| Billing config `mode` | `GET /billing/config` reports campaign checkout as `"sandbox"`, `"live"` or `"disabled"`; `canPay` is false for keys |
| Report `mode` | `get_report` and `GET /reports` count `test` and `live` gifts separately |
| Gift / send | One recipient's prepared gift. `sendId`, `giftId` and `gift_id` are the same ID. |
| Gift link | The private URL a recipient uses to confirm their address (also called claim link, private link or confirmation link) |
| `approvalUrl` | Home in GTM Swag, filtered to one playbook's waiting people (`https://gtmswag.com/?queue=CAMPAIGN_ID`) |
| Review URL | `https://gtmswag.com/campaigns/CAMPAIGN_ID?review=1`, where a person turns a playbook on or reviews a batch |
| Balance | Prepaid workspace funds in cents that playbook gifts draw from |
| Auto-approve | A playbook rule an owner or admin sets: approve gifts within a per-gift limit, the monthly budget and the balance |
| `policyVersion` | A campaign's version number. Send it back as `expectedPolicyVersion` on edits and batch adds; a stale one returns `POLICY_CHANGED`. |
| `blockers` | What must be fixed before a person can approve |
| `nextAction` | What to do after an error; see "Errors and retries" |
| Gifting profile | The focused tool list at `/mcp?profile=gifting`, the same one sign-in connections get; the full key profile at `/mcp` adds workspace administration tools |
| Prepaid offer | `prepaidOffer` from `preview_campaign` on a batch or single campaign: a fixed, tax-inclusive price per recipient, paid at checkout before links work, that lets `mode: "live"` recipients confirm their own address within its `shippingStates`. It appears only when GTM Swag has qualified the gift for it, and never applies to playbooks. |
| Legacy quote and commit | `quote_send` and `commit_send`: an older per-gift path. Commit is signed-in only; use campaigns and playbooks instead. |

## Prices and payment

- **Price per gift** is the catalog `priceCents + shippingCents`, in integer USD cents (search results return it as `estimatedTotalCents`). The total a person approves is all-in: delivery and applicable US sales tax are included in it, not added at payment.
- **Unquoted shipping.** A gift with `shippingQuoteRequired: true` has no delivery cost in its price yet (`shippingCents` is usually 0), so budgets and previews don't include delivery, and auto-approve never approves it: each one waits for a person with reason `shipping_quote`. Its final price is confirmed before it can be ordered with `mode: "live"` and can be higher. Never present that price as a delivered total.
- **Playbooks** draw from a prepaid workspace balance. An owner or admin adds $25 to $20,000 per top-up by card. A playbook gift is charged to the balance when it is approved; a gift cancelled before production, or declined by its recipient, returns its cost to the balance. The balance is kept separately for each `mode`: `get_balance` reports the current one, and funds added while the workspace had `mode: "test"` never pay for gifts that ship.
- **Batch and single campaigns** are paid by card through a Stripe checkout that an owner or admin completes on gtmswag.com.
- **Nothing is charged** when you save a draft, add a person or read anything. Money moves only when an owner or admin approves or pays, or when auto-approve approves a gift within its limits.
- **Recipients never pay.** No subscription or platform fee is charged; you pay only for gifts.

## Choose a gift that can be approved

`search_gifts` ranks current gifts (not retired, priced and not marked unavailable) within `budgetCents` per person, with `estimatedTotalCents` and `campaignEstimateCents` (`recipientCount` × price). It doesn't filter on `verified`: every result can be planned, but with `mode: "live"` only a verified gift can be ordered. `useCase` ranks gifts for the goal: `general` (the default, which infers a goal from `query`), `cold_outreach`, `company_milestone` (a launch, funding round or anniversary), `new_role`, `event_follow_up`, `deal_reengagement`, `customer_thanks` or `team_recognition`. Filters: `excludeFood`, `excludePortrait`, `excludeArtwork`, `excludeAlcohol` and `requireKnownShipping`. `browse_gifts` pages the whole catalog (`limit` up to 100, then `nextCursor`; `detail: "full"` returns every public field). The public REST catalog also lists retired gifts for history.

Filter the REST catalog in this order: `retired: false` first (a retired gift can still show `available: true`), then `available: true` and no `priceQuoteRequired`. Skip any gift with `priceCents: 0`: it is an unpriced placeholder that can't be ordered. Prefer `shippingQuoteRequired: false` when the budget must hold exactly or the playbook uses auto-approve. A flag that is absent, such as `shippingQuoteRequired`, `fixedPrice` or `estimateIncludesDelivery`, means false.

| Field | Meaning |
| --- | --- |
| `id` | Product ID for `productId`. Never invent one. |
| `priceCents`, `shippingCents` | USD cents; their sum is the price per gift |
| `priceLabel`, `shippingLabel` | Display text, for example "Shipping quoted before payment" |
| `available` | False means the gift can't be ordered now. It doesn't reflect `retired`. |
| `retired` | True means archived; never choose it for a new campaign |
| `verified` | True once GTM Swag has confirmed the supplier, price and delivery. Until then the gift can be planned and tried end to end, and its price is an estimate, but it can't be ordered with `mode: "live"` (`PRODUCT_UNVERIFIED`). |
| `fixedPrice` | The price covers the listed personalized configuration; delivery is included unless `shippingQuoteRequired` is true |
| `estimateIncludesDelivery` | The price is an estimate that already includes US delivery, confirmed when the gift is verified |
| `priceQuoteRequired` | The price isn't set; the gift can't be ordered |
| `shippingQuoteRequired` | Delivery isn't priced yet (see "Prices and payment") |
| `leadDays` | Production time estimate, as text; not a delivery date |
| `fulfillmentKind` | `standard`, `food` or `alcohol` |
| `requiresPortrait` | Each person needs a photo (see "One-time batch campaigns") |
| `requiresArtwork` | The gift needs a logo or artwork file |
| `personalization` | The personalization options, as text |
| `personalizationText` | When present, the lettering limits `{label, maxLength, maxLines, lineMaxLength, firstLineRequired?, allowedCharacters?, help}`; set `customization.displayText` within them |
| `quoteDetails` | `{configuration, decoration, inputs, proof}`: what the price covers, the decoration, what the sender provides, and the proof step |
| `deliveryNote`, `dietaryNote` | Delivery and dietary details to pass on |
| `catalogStatus`, `giftType`, `category`, `occasion` | Grouping and display fields |

`quoteDetails.inputs` lists what the sender provides; there is no separate field for each one. Put lettering in `customization.displayText`, a logo in `customization.logoAssetId` (an ID from `upload_artwork`), and anything else in the campaign's `message` or as extra `customization` text fields (up to eight fields of 400 characters each). Ask the user for these; never invent them. GTM Swag staff confirm the details with the customer before production.

## Campaigns

Paths are relative to `/api/v1`. `:id` is a campaign ID (`cmp_…`) unless stated.

| Method and path | Input and result |
| --- | --- |
| `GET /catalog` | Public. Every product, including retired ones; choose `retired: false` and `available: true` |
| `GET /workflow` | Workspace key or session. `{mode, connectorCanSend, approvalRequired, capabilities, instructions, steps, batchSteps, approvalReasons, tracking, recipientRequirements, limits, recovery, reviewUrlTemplate, documentation}` |
| `GET /campaigns`, `GET /campaigns/:id` | Campaigns with `mode`, `status`, `policyVersion`, `monthlyBudgetCents`, `autoApprove` (playbooks, once set), `requiresPayment`, `spentCents`, `sentCount` and `recipients`. Filter the list with `?mode=always_on`, `batch` or `single` |
| `POST /campaigns` | Create fields below. Returns `201` and the saved campaign |
| `PATCH /campaigns/:id` | Changed fields plus `expectedPolicyVersion` (required for keys). Content edits return the campaign to draft unless you set `paused` or `completed`; keys can't set `active` |
| `POST /campaigns/:id/recipients` | `{recipients, expectedPolicyVersion}`. Appends people and raises the limits to fit. A batch returns to draft; an active playbook stays on and needs no version |
| `POST /campaigns/:id/recipients/bulk` | `{action: "message" or "remove", recipientIds, expectedPolicyVersion, message?}` for 1–500 unsent people (MCP `manage_recipients`) |
| `GET /campaigns/:id/preview` | `{campaignId, policyVersion, recipientCount, unitCents, totalCents, currency, alreadySent, blockers?, fulfillmentRequirements?, nextAction, unavailableReason, prepaidOffer?, checkout?}`. No payment or gifts |
| `GET /campaigns/:id/repeats` | `[{recipientId, email, name, priorSendId, approved}]`: people gifted by another campaign |
| `POST /campaigns/:id/repeat-approvals` | Signed-in only. `{recipientIds}` approves reviewed repeats |
| `POST /campaigns/:id/auto-approve` | Signed-in only; see "Auto-approve settings" |
| `POST /campaigns/:id/checkout` | Signed-in owner or admin. `{expectedPolicyVersion, idempotencyKey}`; returns the Stripe checkout |
| `POST /campaigns/:id/launch?cursor=0` | Signed-in only. Prepares up to three paid roster positions per call; returns `{sends, errors, nextCursor}` |
| `GET /campaigns/:id/tracking` | Tracking for one campaign (see "Tracking, events and outcomes") |

### Create fields

Required: `name`, `productId`, `senderName`, `senderCompany`. Batch and single campaigns also need `budgetCents` (1–100,000,000), `perPersonCents` (1–10,000,000, at most the budget) and `maxSends` (1–500); for N people use N × price, the price and N. Playbooks (`mode: "always_on"`) default `perPersonCents` to the gift's price, `budgetCents` to 100,000,000 and `maxSends` to 2,000.

Optional: `mode` (default `batch`), `status` (default `draft`; keys and sign-in connections can't create `active`), `monthlyBudgetCents` (caps a playbook's spend per UTC calendar month; required for auto-approve), `message` (shared note, up to 500 characters after personalization), `description`, `customization` (up to eight text fields of 400 characters), `meetingUrl`, `attribution`, `recipientSource` (`manual` or `workflow`), `recipients` (default `[]`, at most 500) and `idempotencyKey` (8–150 characters). Names and sender fields allow 200 characters.

`customization` keys GTM Swag reads: `displayText` (lettering, checked against the gift's `personalizationText`), `logoAssetId` (an uploaded logo, added to every person's artwork), `giftLinkWelcome` (the gift-link welcome, up to 400 characters with `{{firstName}}` or `{{company}}`) and `giftLinkContactEmail` (a sender contact address shown to recipients). Other keys are stored as text for the order.

### Recipient fields

Each person needs a `name`, a personal LinkedIn `profileUrl`, or `sourceSystem` plus `externalId`. Optional: `email`, `company`, `title`, `context` (research notes, never printed), `message` (overrides the shared note), `customTag` (up to 200 characters, for `{{custom}}`), `assetIds` (up to five artwork IDs), `photo`, `adultRecipientConfirmed`, `details` (see gift details below), `recipientId` (to update a known person), `importId` and `importRowId` (both or neither). Unknown fields return `400 VALIDATION_ERROR`.

`addressMode` is `confirm` (default: the recipient confirms their address through a gift link) or `known`, which needs `name`, `address`, `city`, a US `state` (a two-letter code such as `TX`, or the full name such as `Texas`, which is stored as the code) and a 5- or 9-digit `zip`. `country` is `US` only and defaults to `US`.

`profileUrl` is a personal LinkedIn profile, `https://www.linkedin.com/in/SLUG`, on `linkedin.com`, `www.linkedin.com` or a two-letter country subdomain; it is saved as `https://www.linkedin.com/in/SLUG`. Sales Navigator, company and `/pub/` URLs return `400 VALIDATION_ERROR`. For a pasted list without CRM IDs, set `sourceSystem` to the list's origin (such as `manual` or the event name) and `externalId` to a value that stays the same for each person, such as the LinkedIn slug or row number.

**Notes.** Placeholders are `{{firstName}}` (the first word of `name`), `{{name}}`, `{{company}}` and `{{custom}}` (`customTag`). Values are inserted literally, once; nothing is taken from `profileUrl`, `email` or the source record. A note whose placeholder has no value for a person blocks approval, and a playbook add returns `400 RECIPIENT_MESSAGE_REQUIRED`; drafts may stay incomplete. Every person needs a resolved note of at most 500 characters before approval. The API applies no default note (`message` defaults to empty); the web app's campaign builder pre-fills a gift's suggested note, which often uses `{{company}}`. CSV imports read `message`, `personal_message` or `personal_note`, and `custom_tag`.

**Preview.** `blockers` lists what stops approval (missing note, photo, lettering, 21+ confirmation). `fulfillmentRequirements` lists later steps as `{recipientId, recipientName, recipientEmail, kind, message}`, where `kind` is `photo_sourcing`, `address_confirmation` or `sender_address_required`. `nextAction` is `review` (ready for a person) or `provide_sender_address` (`mode: "live"` needs sender-supplied addresses; give the user `https://gtmswag.com/campaigns/CAMPAIGN_ID` and stop). The server returns `PERSONALIZATION_REQUIRED` when approval or checkout is tried without required lettering.

**Gift details.** Some gifts need a fact about each person, such as their team, a link to their company logo or their patent number. The gift lists these in `recipientDetails` as `{key, label, help, kind, maxLength, required}`; `kind` is `text` or `url` (a web address, with or without https://). Put each answer in `recipient.details` under its key, for example `"details": {"team": "Chicago Cubs"}`. Drafts may leave them blank; preview lists missing ones in `blockers`, and approval, checkout and auto-approve stop with `RECIPIENT_DETAILS_REQUIRED` until every required field is filled. Ask the customer for these facts; don't guess them. CSV imports take one column per detail, named after its key in snake case (`company_logo`, `team`).

**Versions.** Every key-authenticated edit, batch add and checkout sends `expectedPolicyVersion` from the latest read. A missing version returns `400 VALIDATION_ERROR`; a stale one `409 POLICY_CHANGED`. Use the returned `policyVersion` on the next write. An open checkout locks edits until it is cancelled or expires, or until its paid gifts are prepared (`409 CHECKOUT_LOCKED`).

### Checkout and invoices

Batch and single campaigns are paid by card. A signed-in owner or admin starts checkout from the campaign review; keys get `canPay: false`.

| Method and path | Input and result |
| --- | --- |
| `GET /billing/config` | `{enabled, mode, canPay}` |
| `GET /billing/checkouts?campaignId=&limit=50&before=` | Owner or admin. Checkouts with Stripe invoice links; `limit` 1–100; `before` is a creation time |
| `POST /billing/checkouts/:checkoutId/reconcile` | Owner or admin. `{}`; re-checks Stripe and returns the checkout |
| `POST /billing/checkouts/:checkoutId/cancel` | Signed-in owner or admin. `{}`; expires an unpaid checkout |
| `GET /billing/checkouts/:checkoutId/items` | Owner or admin. Paid items with amount, send ID, status and refund eligibility |

Checkout fields: `id`, `campaignId`, `state` (`creating`, `open`, `paid`, `consumed`, `expired`, `cancelled`), `amountCents`, `recipientCount`, `remainingCount`, `launchCursor`, `currency: "usd"`, `mode`, `policyVersion`, `createdAt`, `expiresAt`, and optional `paidAt`, `url`, `invoiceUrl` and `invoicePdf`. The same `idempotencyKey` and campaign version return the existing checkout. After payment, the paid list is prepared in the background; a payment return URL never proves payment, and payment doesn't prove every gift was created. Read tracking.

### Gift link branding and welcome messages

New links take the campaign's sender name and company, `customization.logoAssetId`, `customization.giftLinkContactEmail` and `customization.giftLinkWelcome`. Edit unused, unexpired links directly:

- `GET /campaigns/:id/gift-links` returns each link's `landingMessage`, `messageVersion`, `generation`, sender fields, `contactEmail`, `logoAssetId`, `valueCents`, `canRegift`, `canRequestRefund` and refund progress (MCP `list_campaign_gift_links`).
- `POST /sends/:id/gift-link/message` takes `{generation, expectedMessageVersion, message?, branding?}` (MCP `customize_gift_link_message`).
- `POST /campaigns/:id/gift-links/message` takes `{message?, branding?, links: [{sendId, generation, expectedMessageVersion}]}` for up to 25 links at once, all or nothing (MCP `customize_campaign_gift_links`).
- `branding` is a complete `{senderName, senderCompany, contactEmail, logoAssetId}`; empty contact or logo values hide them. The logo must be a ready PNG, JPEG or WebP owned by the workspace. Messages allow 500 characters after `{{firstName}}`, `{{name}}` and `{{company}}`; an empty string restores the default. Text is shown literally, without HTML.
- A stale version or generation returns `409 GIFT_LINK_CONFLICT`: read again before retrying. Saving never emails anyone or changes the printed note.

### Manage existing unused gifts

- `POST /sends/:id/gift-link/regift` takes `{generation, recipient, message}` (MCP `regift_gift_link`). It keeps the original payment, invalidates the old URL and creates a new 14-day link, for an unused gift whose link shows `canRegift: true` in an active or paused campaign with no checkout in progress. It doesn't email anyone. After an uncertain response, read the link: an advanced generation with the intended recipient means it worked; never retry with the advanced generation.
- `POST /sends/:id/gift-link/revoke` takes `{generation}` (MCP `revoke_gift_link`) and stops an unused link without a refund.
- `GET /campaigns/:id/gift-link-settings` and `GET /campaigns/:id/gift-refunds` read history (MCP `get_campaign_invitations`). `POST /sends/:id/gift-link/refund` is for a signed-in owner or admin; GTM Swag staff review it. Requested and processing don't mean refunded.
- Prospect invitation email and reminders are retired: `POST /sends/:id/gift-link/email` and `PATCH /campaigns/:id/gift-link-settings` return `410 CAPABILITY_RETIRED`.

### Runnable campaign preparation example

Save as `prepare-campaign.mjs`, set `GTM_BASE_URL=https://gtmswag.com` and `GTM_SWAG_KEY` to a workspace key, then run `node prepare-campaign.mjs`. It creates a draft and prints its review URL. It never activates the campaign, opens checkout or sends gifts. Keep the key and create payload unchanged when retrying the same campaign; recipient adds match stable source IDs.

```js
const base = process.env.GTM_BASE_URL;
const key = process.env.GTM_SWAG_KEY;
if (!base || !key) throw new Error("Set GTM_BASE_URL and GTM_SWAG_KEY.");
async function api(path, method = "GET", body) {
  const response = await fetch(new URL("/api/v1" + path, base), {
    method,
    headers: {
      Authorization: `Bearer ${key}`,
      "Content-Type": "application/json",
    },
    ...(body === undefined ? {} : { body: JSON.stringify(body) }),
  });
  const result = await response.json();
  if (!response.ok)
    throw new Error(`${result.code || response.status}: ${result.error}`);
  return result.data;
}
const gift = (await api("/catalog")).find(
  (p) => p.id === "gtm-mini-pinata-v1" && !p.retired,
);
if (!gift) throw new Error("Example gift is unavailable.");
const unit = gift.priceCents + gift.shippingCents;
// Keep this key and create payload unchanged when retrying this campaign.
const createRequest = {
  idempotencyKey: "first-campaign-example-v1",
  name: "First campaign",
  productId: gift.id,
  status: "draft",
  budgetCents: unit,
  perPersonCents: unit,
  maxSends: 1,
  senderName: "Alex",
  senderCompany: "Example Co",
  message:
    "Hi {{firstName}}, a little something to celebrate what you’re building at {{company}}.",
};
let campaign = await api("/campaigns", "POST", createRequest);
campaign = await api(`/campaigns/${campaign.id}`);
const recipient = {
  name: "Jamie Rivera",
  sourceSystem: "example-crm",
  externalId: "lead-jamie-001",
  company: "Northstar",
};
if (
  !campaign.recipients.some(
    (person) =>
      person.sourceSystem === recipient.sourceSystem &&
      person.externalId === recipient.externalId,
  )
) {
  campaign = await api(`/campaigns/${campaign.id}/recipients`, "POST", {
    expectedPolicyVersion: campaign.policyVersion,
    recipients: [recipient],
  });
}
const preview = await api(`/campaigns/${campaign.id}/preview`);
console.log("Preview:", preview);
console.log({
  campaignId: campaign.id,
  reviewUrl: new URL(
    `/campaigns/${campaign.id}${preview.nextAction === "provide_sender_address" ? "" : "?review=1"}`,
    base,
  ).href,
});
// Stop here. A teammate reviews and approves in GTM Swag.
```

## Approval queue and workspace balance

People in an active playbook wait in the approval queue until a signed-in owner or admin approves or skips them, unless the playbook's auto-approve covers their gift. Approving charges the balance and prepares the gift. Keys read the queue and balance here or over MCP; sign-in connections use MCP `list_queue` and `get_balance`, because these REST routes return `401 AUTH_REQUIRED` for OAuth tokens.

| Method and path | Input and result |
| --- | --- |
| `POST /playbooks/:id/people` | `{recipient}` (recipient fields above). Adds one person to an active playbook; no `expectedPolicyVersion`. Returns `{queued, recipientId, alreadyQueued, approvalUrl, approval: {state, reason?, sendId?}}`. Matches a repeat by `sourceSystem` + `externalId`, then `recipientId`, `email` or `profileUrl`. MCP `add_to_playbook` |
| `GET /queue?campaignId=` | An array of `QueueItem`: `{campaignId, campaignName, productId, recipientId, recipient, message, priceCents, blockers, approvalUrl, autoApprove?}`. No paging on REST. MCP `list_queue({campaignId?, limit?, cursor?})` returns `{items, nextCursor}`, `limit` 1–100, default 50 |
| `POST /queue/approve` | Signed-in owner or admin. `{campaignId, recipientId, approveRepeat?}`; charges the balance and returns the gift. Refusals: `402 INSUFFICIENT_BALANCE`, `409 BUDGET_CAP`, `409 REPEAT_REVIEW_REQUIRED`, `409 RECIPIENT_SKIPPED` |
| `POST /queue/skip` | Signed-in owner or admin. `{campaignId, recipientId, reason?}` (up to 200 characters); returns `{skipped: true}` |
| `POST /queue/unskip` | Signed-in owner or admin. `{campaignId, recipientId}`; returns `{skipped: false}` |
| `GET /queue/skipped?campaignId=` | `[{campaignId, campaignName, recipientId, recipient: {name, company?, title?}, reason, skippedAt}]`, newest first |
| `GET /balance` | `{cents, mode, lowBalance, topUpUrl}` |
| `GET /balance/ledger?limit=50` | Newest first: `[{id, kind, cents, sendId, campaignId, note, createdAt}]`, `kind` `topup`, `debit`, `credit` or `adjustment`; `limit` 1–200 |
| `POST /balance/topup` | Signed-in owner or admin. `{cents, idempotencyKey}`, 2,500–2,000,000 cents; returns `{url, sessionId}` for the person to open |

`blockers` use the same checks as approval: `Needs a photo`, `Confirm 21 or older`, `Add gift details`, `Artwork missing`, `Finish personalization`, `Finish the note`, `Gift unavailable`, `Playbook budget reached`, `Playbook send limit reached`, `Gifted before: approve the repeat`, `Monthly budget reached`, `Add funds` and, with `mode: "live"`, `Needs a shipping address` (a person added with `addressMode: "confirm"`; approving returns `LIVE_ADDRESSLESS_UNAVAILABLE`). Any other refusal shows `Needs review`. The balance, monthly budget and playbook limits are shared by waiting people in queue order. With `mode: "live"`, an unverified gift shows no blocker yet, but approving it returns `409 PRODUCT_UNVERIFIED`.

Nothing re-checks a waiting person on its own: adding funds, raising a limit or turning auto-approve on doesn't approve people already in the queue. An owner or admin approves them, or the workflow adds the same person again, which runs auto-approve again when it is on. The balance is kept separately for each `mode`; funds added while the workspace had `mode: "test"` never pay for gifts that ship.

`lowBalance` is true when the balance can't cover everyone waiting, or one more gift from any active playbook (for an auto-approve playbook, its per-gift limit or the gift price if higher). A gift paid from the balance has `fundedBy: "balance"` and needs no checkout (`409 BALANCE_FUNDED`). A balance-paid gift cancelled before production, or declined by its recipient, is credited back.

### Auto-approve settings

`POST /campaigns/:id/auto-approve` (signed-in only) takes `{enabled, perGiftMaxCents?, monthlyBudgetCents?, expectedPolicyVersion?}`: per-gift 100–100,000 cents and monthly 100–100,000,000 cents. Turning it on needs a per-gift limit no higher than a monthly budget. It returns the campaign with `autoApprove: {enabled, perGiftMaxCents, enabledBy, enabledAt}` and a new `policyVersion`. While it is on, raising `monthlyBudgetCents` through `PATCH /campaigns/:id` also needs a signed-in owner or admin.

| Code | HTTP | Meaning |
| --- | --- | --- |
| `HUMAN_APPROVAL_REQUIRED` | 403 | A key or connection tried to change it |
| `TEAM_MANAGER_REQUIRED` | 403 | A member tried to turn it on or raise a limit; members may lower limits or turn it off |
| `MONTHLY_BUDGET_REQUIRED` | 400 | Turning it on needs a monthly budget |
| `VALIDATION_ERROR` | 400 | The per-gift limit is missing or above the monthly budget |
| `NOT_ALWAYS_ON` | 409 | Only playbooks auto-approve |
| `POLICY_CHANGED` | 409 | `expectedPolicyVersion` is stale |

With auto-approve on, an add returns `scheduled` when it raced other adds or its approval met another approval in progress, and every backfill through `POST /campaigns/:id/recipients` is scheduled. Scheduled people are approved in the background, usually within minutes, and `send.auto_approved` records each one. An add that loses every race returns `409 PLAYBOOK_BUSY` and saves nothing.

## Gift links

Customers send outreach through their own tools; GTM Swag never emails gift links. Return actual ready links or the exact unavailable reason, and never build a link, payment or download URL from an ID.

| REST path | MCP tool (standard and ChatGPT) | Input and result |
| --- | --- | --- |
| `GET /campaigns/:id/distribution` | `get_gift_link_records` | `recipientIds`, `giftIds`, `search`, `availability`, `cursor`, `limit`; returns `{version: 1, records, nextCursor, counts, sourceImports}` |
| `POST /campaigns/:id/distribution/exports` | `export_gift_links` | `{scope, preset?, includeUnavailable?, optionalColumns?, columnPrefix?, recipientIds?, giftIds?, search?, availability?}`; returns `{id, downloadUrl, filename, columns, mode, counts, excluded, earliestExpiry}` |
| `GET /distribution/exports/:id` | `download_gift_link_export` | `exportId`; REST returns the CSV as an attachment, the tool returns `{csv, filename}` as text to hand to the user |
| `POST /campaigns/:id/distribution/imports` | `save_recipient_import` | `{importId, headers, retainedColumnIndices, rows}`; returns the import summary |
| `POST /campaigns/:id/distribution/import-chunks` | `upload_gift_source_chunk` | `{uploadId, partIndex, partCount, totalBytes, data}` |
| `DELETE /campaigns/:id/distribution/source-columns` | `remove_recipient_source_columns` | `{importId, indices}` (original column positions); returns the import summary |

MCP tools add `campaignId`; REST takes it from the path. REST selections are comma-separated; tool arguments are arrays (1–500 IDs). `limit` is 1–100 (default 50). Keep the selection fixed while following `nextCursor`; a changed roster returns `409 CURSOR_STALE`. `search` is a case-insensitive match on name, email, company, external ID, source system and profile URL. `giftIds` are send IDs.

Each record has `recipient_id`, `source_system`, `external_id`, `name`, `company`, `email`, `profile_url`, `campaign_id`, `gift_id`, `product_id`, `gift_link`, `gift_status`, `availability`, `availability_reason`, `gift_expires_at`, `link_generation`, `mode`, `import_id`, `import_row_id` and `review_url`. `gift_link` is empty unless `availability` is `ready`. `counts` is `{total, ready, unavailable}` over the whole selection.

**How many links in chat.** Show up to 25 links as a table in a conversation; for more, export a CSV. A person can't open `downloadUrl` in a browser without the same credentials, so in chat use the tool's CSV text, or point the user to the campaign's page in GTM Swag, where a teammate can export the same file.

**Exports.** `scope` is `all` (no filters), `selection` (`recipientIds` or `giftIds`) or `filtered` (`search`, `availability` or IDs). Preset `original` (default) keeps the retained source columns and appends `gift_link`, `gift_status` and `gift_expires_at`; `compact` keeps recipient identity plus those columns. `includeUnavailable: true` adds rows without links, for a full status report. Check `counts`, `excluded` and `earliestExpiry` before handing a file over. Download URLs need the same credentials, expire after 24 hours, and recheck access and link eligibility; they aren't public links. Files already downloaded can't be recalled.

**Access.** Reads, exports and downloads need workspace credentials and, for OAuth, `gifts:links:read`; imports and column removal need `gifts:write`; ChatGPT also needs `gifts:read`. OAuth tokens reach only these REST routes.

**Source imports.** Limits: 500 rows per campaign, 100 columns, 10,000 characters per cell, 5 MiB per source CSV. `upload_gift_source_chunk` takes parts of at most 150,000 bytes before base64 (200,000 characters after), up to 384 parts and 32 MiB in all. Reuse `uploadId`, the metadata and exact bytes after a timeout; when `retryAfterMs` is present, resend the last part after it until `complete: true`. Unfinished uploads expire after six hours; a workspace can stage four uploads or 64 MiB at once (`429 UPLOAD_CAPACITY`). Finish an import before removing columns. Request examples: distribution requests.

## Tracking, events and outcomes

| Method and path | Input and result |
| --- | --- |
| `GET /tracking` | `{items, nextCursor}`; filters `campaignId`, `externalId`, `status`, `sendId`, `limit` (1–100, default 50), `cursor`. A snapshot; continue until `nextCursor` is null (MCP `get_tracking`) |
| `GET /sends/:id/tracking` | One tracking item |
| `GET /events` | `{items, nextCursor, hasMore}`; the same filters plus `type` (one event type per request; omit it for all). An ordered change feed: store `nextCursor` after every poll, including empty ones (MCP `list_tracking_events`) |
| `GET /sends`, `GET /sends/:id` | Gifts with status, `mode`, portrait state and attribution (MCP `list_sends`, `get_send`) |
| `GET /campaigns/:id/results` | `totalSends`, `cancelled`, `delivered`, `replied`, `meetings`, `meetingsHeld`, `committedCents`, `costPerMeetingCents`, `outcomeSource`, `meetingDenominator` (MCP `campaign_results`) |
| `GET /campaigns/:id/export` | CSV of gift, campaign and reference IDs, `mode`, status, outcome and total cents. No addresses, emails, notes or gift links |
| `POST /sends/:id/outcomes` | `{outcome, externalEventId?, source?, occurredAt?}`; `outcome` is `none`, `replied`, `meeting_booked` or `meeting_held` (MCP `record_outcome`, full API-key list only) |

Tracking items carry `sendId`, `campaignId`, `attribution`, `status`, `outcome`, `orderState`, `mode`, `createdAt`, `totalCents`, `carrier`, `trackingNumber` and `trackingUrl`. Event types, payloads and status values are listed in the agent guide's "Tracking and webhooks". Reads by keys and connections leave out any address a recipient typed into their gift link; a sender-supplied address stays visible.

**Attribution.** Campaigns and gifts accept `attribution`: `externalId`, `source`, `contactId`, `accountId`, `opportunityId` (up to 200 characters each) and `metadata` (up to 10 string values). Gift values override campaign values, and a recipient's `externalId` becomes the gift's `attribution.externalId` by default. Keep personal data and secrets out of it.

**Outcomes** are what the customer reports, never inferred from delivery or opens. External event IDs deduplicate per workspace and source; a changed retry returns `409 IDEMPOTENCY_CONFLICT`. `occurredAt` defaults to now and may be at most five minutes ahead. Results count only non-cancelled gifts; cost per meeting is committed cost divided by gifts marked `meeting_booked` or `meeting_held`. This is not revenue.

## Signed outgoing webhooks

A signed-in owner or admin creates and removes webhooks (Integrations → For developers → Signed webhooks). Owner and admin keys can list them and read or retry deliveries; other keys get `403 HUMAN_APPROVAL_REQUIRED`, and sign-in connections have no access.

| Method and path | Input and result |
| --- | --- |
| `POST /webhooks` | Signed-in. `{url, events}`; returns `{id, url, events, createdAt, secret}`. The secret is shown only here |
| `GET /webhooks` | Active webhooks, without secrets (MCP `list_webhooks`) |
| `DELETE /webhooks/:id` | Signed-in. Stops future deliveries |
| `GET /webhooks/:id/deliveries?limit=50&before=&source=gift` | `{items, nextBefore}`; rows have `id`, `event_seq`, `type`, `source`, `state`, `attempts`, `next_at` (Unix ms), `last_status`, `last_error`. `source=playbook` lists `playbook.person_waiting` deliveries |
| `POST /webhooks/:id/deliveries/:deliveryId/retry?source=gift` | Starts a new cycle of up to eight attempts for a failed delivery |

`events` is 1–10 of `all`, `send.created`, `send.gift_link_changed`, `send.address_confirmed`, `send.link_opened`, `send.status_changed`, `send.order_changed`, `send.outcome_reported`, `send.tracking_changed`, `send.auto_approved` and `playbook.person_waiting`. The URL must be public HTTPS. Deliveries follow no redirects, time out after 10 seconds, and are tried up to eight times, waiting 1, 2, 4, 8, 16, 32 and 64 minutes.

Each delivery body is the event as JSON (the same shape as a `GET /events` item; `playbook.person_waiting` has no `cursor`). Headers:

```text
X-GTM-Swag-Event-Id: <event ID>
X-GTM-Swag-Signature: t=<Unix seconds>,v1=<hex HMAC-SHA256(secret, t + "." + rawBody)>
```

Verify the raw body before parsing, compare in constant time, accept only a short timestamp window and deduplicate by event ID. Delivery is at least once; a retry keeps the body and event ID with a fresh signature. In Node.js:

```js
import { createHmac, timingSafeEqual } from "node:crypto";

// rawBody: the exact request body as a string; header: X-GTM-Swag-Signature.
export function verifyGtmSwag(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(
    header.split(",").map((part) => part.trim().split("=")),
  );
  const sentAt = Number(parts.t);
  if (!sentAt || Math.abs(Date.now() / 1000 - sentAt) > toleranceSeconds)
    return false;
  const expected = createHmac("sha256", secret)
    .update(`${parts.t}.${rawBody}`)
    .digest();
  const given = Buffer.from(parts.v1 || "", "hex");
  return given.length === expected.length && timingSafeEqual(given, expected);
}
```

## Team and workspace

| Method and path | Input and result |
| --- | --- |
| `GET /team/members` | `[{userId, email, name, role, joinedAt}]` (MCP `list_team_members`) |
| `GET /team/invitations` | Owner or admin. The latest 100 invitations (MCP `list_team_invitations`) |
| `POST /team/invitations` | Signed-in owner or admin. `{email, role}` (`admin` needs the owner); returns `{invitation, inviteUrl, emailQueued}` |
| `DELETE /team/invitations/:id` | Signed-in owner or admin. Revokes an unused invitation |
| `PATCH /team/members/:userId` | Signed-in owner. `{role}`, `admin` or `member` |
| `DELETE /team/members/:userId` | Signed-in owner or admin; the owner can't be removed, and removing an admin needs the owner. Removing a member revokes their keys |
| `GET /workspaces` | Workspaces you can reach; a key sees only its own |
| `GET /settings`, `PATCH /settings` | Read sender defaults; owners and admins update `company`, `name`, `meetingUrl`, `website`, `brandColor` (MCP `get_workspace_settings`, `update_workspace_settings`) |

Invitation links expire after seven days and need the invited email to sign in. `emailQueued` reports queuing, not delivery. Keys get `403 HUMAN_APPROVAL_REQUIRED` for team changes and never see an `inviteUrl`. A workspace limited to previewing returns `403 DEMO_LIMITED` for keys, webhooks, photo imports, AI notes, list uploads and artwork uploads.

## Workspace reports

`GET /reports` and `GET /reports/export.csv` take `from` and `to` (inclusive UTC dates, `YYYY-MM-DD`, filtering gift creation), optional `campaignId`, and `mode` `test` or `live` (default `test`). JSON is `{mode, from, to, totals, campaigns, outcomeSource: "team_reported"}`, with gift and status counts, non-cancelled `committedCents`, `reportedReplies` and `reportedMeetings` per campaign. The CSV holds campaign totals, not recipient details (MCP `get_report`).

## Private artwork and recipient photos

**Upload.** `POST /artwork` takes raw file bytes with `Content-Type` `image/png`, `image/jpeg`, `image/webp` or `application/pdf`, and `X-Artwork-Filename` with a matching extension (letters, digits, spaces, dots, underscores, parentheses and hyphens; up to 180 characters). Add `X-Artwork-Purpose: portrait` for a recipient photo. Up to 5 MiB. Returns `201` with `{data: {id, filename, mime, bytes}}`. MCP `upload_artwork` takes `{filename, mime, base64, purpose?}` with up to 900,000 base64 characters (about 675 KB). SVG and HTML are refused.

**Photo URLs.** `POST /artwork/import-photo` (MCP `import_recipient_photo`) takes `{"url": "https://example.com/person.jpg", "rightsConfirmed": true}` for a direct public HTTPS PNG, JPEG or WebP the user has rights to, and returns an artwork asset. LinkedIn and sharing pages aren't image URLs.

**Use.** Put a logo ID in `customization.logoAssetId`, per-person artwork in `assetIds` (up to five), and a portrait in `photo: {source: "upload", assetId: "asset_…"}`. Without a photo, `photo: {source: "linkedin", profileUrl: "https://www.linkedin.com/in/their-name"}` asks GTM Swag staff to source one; nothing is scraped, and the customer approves the photo before production. Portraits must be at least 256×256 pixels, at most 12,000 pixels a side and 40 megapixels; PDFs and logos don't count as portraits.

**Approve or replace a photo.** `POST /sends/:id/photo` (MCP `update_recipient_photo`) takes `{version, action: "approve", rightsConfirmed: true}`, `{version, action: "replace", assetId}` or `{version, action: "reject"}`, with the current portrait `version`. Only with the user's explicit authorization for that image. Photo changes close after order review. `Send.portrait` reports `state` (`awaiting_photo`, `awaiting_approval`, `approved`), `version`, `assetId`, `sourceUrl` and `approvedAt`.

**Read.** `GET /artwork/:id` downloads a workspace file; `?preview=1` shows an image inline. Files are private.

**Limits per workspace:** 1,000 stored files and 3,000 MiB; 550 uploads, 560 upload attempts and 550 photo imports per UTC day. Removing artwork from a draft keeps the file.

**CSV columns.** Photo gifts accept `photo_url` (or `image_url`) and `linkedin_url` (or `linkedin`, `linkedin_profile`, `profile_url`); alcohol gifts accept `adult_recipient_confirmed` (`true` or `false`, never inferred). A direct image wins; the LinkedIn profile is the fallback.

## MCP reference

`POST https://gtmswag.com/mcp` with JSON-RPC and the same bearer key or an OAuth token. The endpoint is stateless and returns JSON; it doesn't stream SSE. An unauthenticated request returns `401` with a `WWW-Authenticate` header pointing to `/.well-known/oauth-protected-resource/mcp`; an authenticated `GET` returns `405`. Supported versions: `2026-07-28`, `2025-11-25`, `2025-06-18` and `2025-03-26`.

- **`2026-07-28`:** use `server/discover` instead of `initialize`. Every request carries `params._meta` with `io.modelcontextprotocol/protocolVersion: "2026-07-28"` and `io.modelcontextprotocol/clientCapabilities: {}` (optional `io.modelcontextprotocol/clientInfo: {name, version}`), and matching `MCP-Protocol-Version` and `Mcp-Method` headers; `tools/call` also sends `Mcp-Name` equal to `params.name`. Complete results include `resultType: "complete"` and server information in `_meta`.
- **Older versions:** `initialize` with `protocolVersion`, `capabilities: {}` and `clientInfo: {name, version}`, then `notifications/initialized`, then the negotiated `MCP-Protocol-Version` header. Send `Accept: application/json, text/event-stream`. Notifications return `202`.
- Unsupported versions return `400` with JSON-RPC error `-32022` and `error.data.supported`; header mismatches return `-32020`; invalid parameters `-32602`. Requests with a disallowed `Origin` are refused.

Every tool has a `title` (also in `annotations.title`) and a description of when to use it; `create_campaign`, `add_to_playbook`, `approve_gift` and `search_gifts` publish an input `examples` entry. Results carry `structuredContent: {data}` plus the same JSON as text. Failures set `isError: true` with the error envelope, even on HTTP 200.

**Prompts and resources.** `prompts/list` offers `start_playbook {goal, budget?}`, `add_people_to_playbook {campaignId}` and `prepare_batch_campaign {goal?}`; each returns instructions naming tools in order and changes nothing. `resources/list` offers `gtmswag://guide` (the agent guide) and `gtmswag://catalog/summary` (current gifts with ID, price, lead time and needs), both `text/markdown`. Unknown URIs return `-32002`.

**Tool profiles.** A key at `/mcp` sees the full API-key list of 46 tools: everything below except the signed-in tools (calling one returns `HUMAN_APPROVAL_REQUIRED`). A key at `/mcp?profile=gifting` sees 31 tools, the focused list that sign-in connections get: the tools with a scope below. OAuth connections see the tools their granted scopes allow (all 31 with every scope); others return `403 INSUFFICIENT_SCOPE`. `record_outcome` is only in the full API-key list. ChatGPT's endpoint, `/chatgpt/mcp`, has its own 12 tools (https://gtmswag.com/guides/chatgpt.md).

**Sign-in connections (OAuth).** Authorization code with PKCE S256 and dynamic client registration. Discovery: `/.well-known/oauth-protected-resource/mcp` (resource `https://gtmswag.com/mcp`, with `resource_documentation`) and `/.well-known/oauth-authorization-server`. Endpoints: `GET /oauth/authorize` (sign in and choose the workspace), `POST /oauth/register` (JSON client metadata), `POST /oauth/token` and `POST /oauth/revoke` (form-encoded), and `/oauth/disconnect` (review and revoke this workspace's connections). Include `resource=https://gtmswag.com/mcp` in authorization and token requests. Scopes: `gifts:read`, `gifts:write` and `gifts:links:read` (granted separately; older grants don't include it).

Registered callbacks must match exactly: `https://claude.ai/api/mcp/auth_callback`, ChatGPT's `https://chatgpt.com/connector_platform_oauth_redirect` or `https://chatgpt.com/connector/oauth/{callback_id}`, loopback `http` callbacks on `127.0.0.1`, `[::1]` or `localhost` with any port and path (RFC 8252), and `cursor://`, `vscode://`, `vscode-insiders://`, `windsurf://` and `claude://` app callbacks, with no query, fragment or credentials. Anything else is `invalid_redirect_uri`.

Access tokens last 15 minutes and refresh tokens rotate on every use. A grant stays valid for 30 days after consent or its latest refresh, and never more than 365 days after consent. Revoking, disconnecting or leaving the workspace ends it at once; replaying a used refresh token revokes it. ChatGPT connections on `/chatgpt/mcp` follow the same rules with their own issuer (`/.well-known/oauth-authorization-server/chatgpt`) and disconnect page (`/chatgpt/oauth/disconnect`); workspace keys don't work there.

**Claude Code:** `claude mcp add --transport http gtm-swag https://gtmswag.com/mcp`, then `/mcp` to sign in (recommended: no key to store). With a key, reference an environment variable from `.mcp.json`, as below with `"Authorization": "Bearer ${GTM_SWAG_KEY}"`; Claude Code expands it when it loads. **Claude:** **Customize → Connectors → + Add → Add custom connector** with `https://gtmswag.com/mcp`. Other clients with a key:

```json
{
  "mcpServers": {
    "gtm-swag": {
      "type": "http",
      "url": "https://gtmswag.com/mcp",
      "headers": { "Authorization": "Bearer YOUR_GTM_SWAG_KEY" }
    }
  }
}
```

**Tools.** `read`, `write` and `links` mean `gifts:read`, `gifts:write` and `gifts:links:read`; these tools make up the gifting profile. `—` means the tool is only in the full API-key list. `signed-in` marks the 15 tools only a person can use: they are listed only when someone signed in to gtmswag.com calls MCP from their own browser, never for a key or sign-in connection, and calling one with a key returns `403 HUMAN_APPROVAL_REQUIRED`.

| Tools | Scope | Arguments and notes |
| --- | --- | --- |
| `get_workflow_guide` | read | `{}`; `mode`, playbook `steps`, `batchSteps`, `approvalReasons`, `tracking`, limits and `recovery` by code |
| `search_gifts` | read | `{query?, useCase?, budgetCents?, recipientCount?, limit?, excludeFood?, excludePortrait?, excludeArtwork?, excludeAlcohol?, requireKnownShipping?}`; `{useCase, budgetCents, recipientCount, items, nextStep}` with `estimatedTotalCents` and `campaignEstimateCents` |
| `get_gift` | read | `{productId}`; every public field of one gift |
| `browse_gifts` | read | `{includeRetired?, detail?: "compact" or "full", limit?, cursor?}`; `limit` 1–100 |
| `list_campaigns`, `get_campaign` | read | `{mode?}` / `{id}`; includes `autoApprove` (read-only) |
| `create_campaign`, `update_campaign` | write | Create fields / `{id, expectedPolicyVersion, ...changed}`; OAuth creates drafts only |
| `add_to_playbook` | write | `{id, recipient}`; see `POST /playbooks/:id/people` |
| `add_recipients` | write | `{id, expectedPolicyVersion, recipients}`; an active playbook needs no version and stays on |
| `manage_recipients` | write | `{id, action, recipientIds?, emails?, expectedPolicyVersion, message?}` |
| `preview_campaign`, `get_campaign_repeats` | read | `{id}` |
| `list_queue` | read | `{campaignId?, limit?, cursor?}`; `{items, nextCursor}` |
| `get_balance` | read | `{}`; `{cents, mode, lowBalance, topUpUrl}` |
| `list_sends`, `get_send` | read | `{campaignId?}` / `{id}` (send ID) |
| `get_tracking`, `list_tracking_events` | read | Tracking filters above |
| `campaign_results`, `get_report` | read | `{id}` / `{from?, to?, campaignId?, mode?}` |
| `get_workspace_settings` | read | `{}` |
| `get_gift_link_records`, `export_gift_links`, `download_gift_link_export`, `list_campaign_gift_links` | links | Gift-link inputs above; `list_campaign_gift_links` takes `{id}` |
| `save_recipient_import`, `upload_gift_source_chunk`, `remove_recipient_source_columns` | write | Source-import inputs above |
| `upload_artwork`, `import_recipient_photo` | write | `{filename, mime, base64, purpose?}` / `{url, rightsConfirmed: true}` |
| `update_workspace_settings` | — | Partial settings; owner or admin |
| `customize_gift_link_message`, `customize_campaign_gift_links` | — | `{id, generation, expectedMessageVersion, message?, branding?}` (send ID) / `{id, message?, branding?, links}` (campaign ID) |
| `regift_gift_link`, `revoke_gift_link` | — | `{id, generation, recipient, message}` / `{id, generation}`; send ID |
| `update_recipient_photo` | — | `{id, version, action, assetId?, rightsConfirmed?}`; send ID |
| `get_campaign_invitations` | — | `{id}`; history and refunds |
| `get_billing_config`, `reconcile_campaign_checkout`, `list_invoices` | — | `{}` / `{id}` (checkout ID) / `{campaignId?, limit?, before?}` |
| `list_team_members`, `list_team_invitations`, `list_webhooks` | — | `{}` |
| `quote_send` | — | Legacy per-gift quote of an approved roster; saves a 15-minute quote |
| `record_outcome` | — | `{id, outcome, source?, externalEventId?, occurredAt?}`; only when the user reports an outcome |
| `approve_gift`, `skip_gift`, `unskip_gift` | signed-in | `{campaignId, recipientId, approveRepeat?}` / `{campaignId, recipientId, reason?}` / `{campaignId, recipientId}`; owner or admin |
| `create_balance_topup` | signed-in | `{cents, idempotencyKey}`; owner or admin; returns `{url, sessionId}` |
| `approve_campaign_repeats`, `create_campaign_checkout`, `cancel_campaign_checkout`, `launch_campaign`, `commit_send` | signed-in | Campaign review, payment and preparation |
| `invite_team_member`, `revoke_team_invitation`, `update_team_member`, `remove_team_member`, `create_webhook`, `remove_webhook` | signed-in | Team and webhook changes |

A discovery request:

```sh
curl --fail-with-body "$GTM_BASE_URL/mcp" \
  -H "Authorization: Bearer $GTM_SWAG_KEY" \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H 'MCP-Protocol-Version: 2026-07-28' \
  -H 'Mcp-Method: server/discover' \
  -d '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}'
```

For a tool call, keep the metadata, set `Mcp-Method: tools/call` and `Mcp-Name: TOOL_NAME`, and send `params.name` and `params.arguments`. An older-version read:

```sh
curl --fail-with-body "$GTM_BASE_URL/mcp" \
  -H "Authorization: Bearer $GTM_SWAG_KEY" \
  -H 'Content-Type: application/json' \
  -H 'MCP-Protocol-Version: 2025-11-25' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_report","arguments":{"mode":"test"}}}'
```
