# GTM Swag: agent gifting guide

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. When you don't know a recipient's address, they confirm it through a private gift link that the customer shares in their own outreach.

**Good moments for a gift:** a new role, an event or webinar follow-up, a funding round, a product launch, a closed deal or a renewal. A gift is a reason to start a conversation, not a guarantee of one: never promise a reply or a meeting. GTM Swag tracks gifts, links and deliveries, not replies or meetings, so never infer or ask for those either (see "Tracking and webhooks").

**Status: read `mode` first.** `get_workflow_guide` (REST `GET https://gtmswag.com/api/v1/workflow`) returns the workspace's `mode`. Each gift keeps the `mode` it was created in.

- `mode: "test"`: every workspace starts here. Everything works end to end (playbooks, approvals, the balance, checkout, gift links and address confirmation), but no gift is produced or shipped, and payments run in Stripe's test environment, so no real money moves. Don't send these gift links to real prospects. **To enable delivery for a workspace, a person on the team emails eric@betterswag.com** (or uses https://gtmswag.com/support); GTM Swag staff turn it on. No tool or key can.
- `mode: "live"`: approved gifts are produced and shipped. Two rules apply:
  - **A sender-supplied US address** (`addressMode: "known"`). A person added with `addressMode: "confirm"` is saved, but the approval queue shows the blocker "Needs a shipping address", approving their gift returns `409 LIVE_ADDRESSLESS_UNAVAILABLE`, and auto-approve leaves them waiting with reason `blocked`. A recipient can confirm their own address only through a prepaid offer on a batch or single campaign (below); playbooks never have one.
  - **A verified gift.** Until GTM Swag has confirmed a gift's supplier, price and delivery (`verified: true` in the catalog), it can be planned, saved and tried end to end, but approving or paying for it returns `409 PRODUCT_UNVERIFIED`. The queue doesn't show a blocker for this yet, and auto-approve leaves the person waiting with reason `blocked`.
- On `LIVE_ADDRESSLESS_UNAVAILABLE`, keep the saved draft or person. With the user's authorization, add their US address with `addressMode: "known"`; otherwise stop and report it. Never invent an address.
- **Prepaid offer.** For a batch or single campaign with `mode: "live"`, `preview_campaign` returns `prepaidOffer` (`unitCents`, `shippingStates`, `claimDays: 14`) only when GTM Swag has qualified the gift for it: a verified gift with a fixed, tax-inclusive price and known shipping. An owner or admin pays `unitCents` per recipient at checkout before any link works; each recipient then confirms an address in one of `shippingStates` through their gift link. Without an offer, the preview returns `nextAction: "provide_sender_address"`. Playbooks are paid from the balance, so a prepaid offer never applies to them.
- **When delivery is turned on.** Playbooks stay on and keep their people and auto-approve rules. Gifts approved from then on ship and draw from a separate balance that starts empty: funds added before delivery was enabled stay in the earlier balance and never pay for gifts that ship. Monthly budgets count only spend from the current `mode`. People added with `addressMode: "confirm"` now wait with "Needs a shipping address". Gifts created earlier keep `mode: "test"` and never ship.

**How do I…**

- Gift each person a trigger names: the playbook loop below (`add_to_playbook`, REST `POST /api/v1/playbooks/CAMPAIGN_ID/people`).
- Gift a fixed list once: "One-time batch campaigns".
- Get one recipient's gift link: `get_gift_link_records` (REST `GET /api/v1/campaigns/CAMPAIGN_ID/distribution?recipientIds=RECIPIENT_ID`).
- Get many links: up to 25 as a table in the conversation; more as a CSV with `export_gift_links`, then `download_gift_link_export`.
- Know who approves, pays or adds funds: "Who can do what".
- Recover from an error: follow its `nextAction`; see "Errors and retries".
- Hear about opens and deliveries: `list_tracking_events` (REST `GET /api/v1/events`) or a signed webhook.
- Get gifts shipping: a person emails eric@betterswag.com to enable delivery.
- Connect Claude, ChatGPT, Claude Code or a REST tool: "Connect".

Links: REST API https://gtmswag.com/api/v1 · MCP https://gtmswag.com/mcp · ChatGPT MCP https://gtmswag.com/chatgpt/mcp · API reference https://gtmswag.com/api-docs.md · OpenAPI https://gtmswag.com/openapi.json · Connection guide https://gtmswag.com/guides/connect.md · Everything in one file https://gtmswag.com/llms-full.txt

## Start here: the playbook loop

A playbook is a campaign with `mode: "always_on"`: one gift, one note template and an optional monthly budget. Each time a trigger fires (a new role, a signup, a closed deal), add one person. Use a batch campaign (`mode: "batch"`) only for a fixed list, and `mode: "single"` for one person.

1. **Choose a gift.** `search_gifts({useCase?, query?, budgetCents?, recipientCount?})` ranks current gifts for the goal (with `mode: "live"`, pick one with `verified: true`); `get_gift({productId})` returns one gift's full requirements. REST: `GET /api/v1/catalog` (public). Show the user a few options and confirm the choice.
2. **Create the playbook**, or reuse one from `list_campaigns({mode: "always_on"})`. `create_campaign` (REST `POST /api/v1/campaigns`) with:

   ```json
   {
     "mode": "always_on",
     "status": "draft",
     "name": "New VP of Sales",
     "productId": "gtm-mini-pinata-v1",
     "senderName": "Jordan Lee",
     "senderCompany": "Example Co",
     "message": "Congrats on the new role, {{firstName}}. A little something for your first week.",
     "monthlyBudgetCents": 50000,
     "idempotencyKey": "playbook-new-vp-sales-v1"
   }
   ```

   Keep the returned `id` (`cmp_…`). Leave out `recipients`. `budgetCents`, `perPersonCents` and `maxSends` are optional for playbooks and default from the gift; a playbook holds up to 2,000 people.
3. **A person turns it on.** Give the user `https://gtmswag.com/campaigns/CAMPAIGN_ID?review=1`. A signed-in teammate checks the gift and note and selects **Turn on playbook**; nothing is charged then. On the same page an owner or admin can turn on auto-approve. Agents can do neither. Adds to a draft or paused playbook fail with `409 PLAYBOOK_OFF`. Once it is on, change its settings in the web app: an `update_campaign` (REST `PATCH /api/v1/campaigns/CAMPAIGN_ID`) that changes the playbook (its gift, note, limits or people) returns it to draft, unless it sets `status` to `paused` or `completed`, and adds then fail with `PLAYBOOK_OFF` until a teammate turns it on again.
4. **Add a person when a trigger fires.** `add_to_playbook({id, recipient})`, or REST `POST /api/v1/playbooks/CAMPAIGN_ID/people`:

   ```json
   {
     "recipient": {
       "name": "Jamie Chen",
       "company": "Northstar",
       "title": "VP of Sales",
       "sourceSystem": "hubspot",
       "externalId": "12345",
       "addressMode": "confirm"
     }
   }
   ```

   A `200` response:

   ```json
   {
     "data": {
       "queued": true,
       "recipientId": "recipient_0123456789abcdef0123456789abcdef",
       "alreadyQueued": false,
       "approvalUrl": "https://gtmswag.com/?queue=CAMPAIGN_ID",
       "approval": { "state": "waiting", "reason": "auto_approve_off" }
     }
   }
   ```

   Each person needs a `name`, a personal LinkedIn `profileUrl`, or `sourceSystem` plus `externalId`. `email` and `company` are optional; never invent them. Always send `sourceSystem` and `externalId`, so a repeat returns `alreadyQueued: true` and never approves or charges twice (for a pasted list, see "Repeats and idempotency"). With a known US address, send `addressMode: "known"` with `address`, `city`, `state` (`TX`, or the full name `Texas`, stored as `TX`), `zip` and `country: "US"` (the default; US only). If the note uses `{{firstName}}`, `{{name}}` or `{{company}}`, the person needs that field, or the add fails with `400 RECIPIENT_MESSAGE_REQUIRED` and nothing is saved. The playbook stays on, and no `expectedPolicyVersion` is needed.

   `profileUrl` must be a personal profile, `https://www.linkedin.com/in/SLUG` (https, on `linkedin.com`, `www.linkedin.com` or a two-letter country subdomain such as `uk.linkedin.com`); it is saved as `https://www.linkedin.com/in/SLUG`. Sales Navigator (`/sales/…`), company (`/company/…`) and `/pub/` links return `400 VALIDATION_ERROR`; leave `profileUrl` out rather than send one.
5. **Hand waiting gifts to a person.** `list_queue({campaignId?, limit?, cursor?})` lists the people waiting, each with `blockers`, `approvalUrl` and, while auto-approve is on, `autoApprove: {state, reason?}`. Give the user the `approvalUrl` and the reason in plain words. An owner or admin approves or skips each gift there.
6. **Keep the balance funded.** `get_balance()` returns `{cents, mode, lowBalance, topUpUrl}`. When `lowBalance` is true, or a person waits with reason `low_balance`, give the user `topUpUrl` (`https://gtmswag.com/settings?tab=billing`), where an owner or admin adds funds.

### Approval states

`approval.state` in every add result:

| `state` | Meaning | What to do |
| --- | --- | --- |
| `waiting` | The gift waits for an owner or admin; `reason` says why | Give the user `approvalUrl` and the reason |
| `auto_approved` | Auto-approve approved the gift and paid it from the balance; `sendId` names the gift | Tell the user; track it like any gift |
| `approved` | A person already approved this person's gift; `sendId` names it | Nothing |
| `scheduled` | Auto-approve is finishing it in the background, usually within minutes | Watch `send.auto_approved`, or read `list_queue` later; don't add them again |
| `skipped` | An owner or admin skipped this recipient | Don't re-add them; an owner or admin can undo the skip on Home |

`queued` is true while the state is `waiting` or `scheduled`. Without auto-approve, every person waits with `auto_approve_off` and nothing is charged.

### Waiting reasons

`approval.reason`, the queue's `autoApprove.reason` and the `playbook.person_waiting` webhook use the same codes:

| `reason` | Meaning | What to do |
| --- | --- | --- |
| `auto_approve_off` | The playbook has no auto-approve rule | An owner or admin approves at `approvalUrl`, or turns on auto-approve |
| `over_gift_limit` | The gift costs more than the per-gift limit | An owner or admin approves it, or raises the limit |
| `monthly_cap` | This month's playbook budget is used up | An owner or admin raises the monthly budget and approves, or it waits for next month |
| `low_balance` | The balance can't cover the gift | An owner or admin adds funds at `topUpUrl`. Adding funds doesn't re-check people already waiting: an owner or admin approves them at `approvalUrl`, or add the same person again (same `sourceSystem` and `externalId`) so auto-approve evaluates them again |
| `repeat_recipient` | The person already received a gift from another campaign | An owner or admin approves the repeat; auto-approve never does |
| `photo_review` | A recipient photo must be sourced or approved | A teammate reviews the photo in the web app |
| `age_restricted` | Age-restricted gifts always wait | An owner or admin confirms the recipient is 21 or older and approves |
| `shipping_quote` | The gift's delivery isn't priced yet (`shippingQuoteRequired: true`), so its final cost could pass the per-gift limit; these gifts always wait | An owner or admin approves it from the queue after checking the quote, or choose a gift with known shipping |
| `email_unverified` | The owner or admin who turned on auto-approve hasn't verified their email | They verify it; meanwhile any owner or admin can approve |
| `blocked` | Anything else, such as an unavailable gift or, with `mode: "live"`, an unverified gift or a person without a sender-supplied address (queue blocker "Needs a shipping address") | Read the queue item's `blockers`; fix them with the user |

`monthly_cap` and the error `BUDGET_CAP` are the same limit: the first says why a person waits, the second is what approving past the budget returns. Don't create another playbook to get around a budget or limit.

**Retrying people who wait.** Nothing re-checks a waiting person on its own: not adding funds, not raising a limit, not turning auto-approve on. Either an owner or admin approves them in the queue, or your workflow adds the same person again; with auto-approve on, that re-add runs the rule again and can approve and charge them, still within its limits. Re-adding is safe: it never creates a second gift.

### Auto-approve

A signed-in owner or admin can turn on auto-approve on the playbook's page, with a per-gift limit (100–100,000 cents) and the playbook's `monthlyBudgetCents` (100–100,000,000 cents, required while it is on). Then each person added to the active playbook whose gift costs no more than the per-gift limit, fits the rest of this month's budget and is covered by the balance is approved and paid from the balance on behalf of the owner or admin who turned it on. The gift's send carries `autoApproved: true` and `fundedBy: "balance"`, and a `send.auto_approved` event is recorded. Anything else waits with a reason, and some gifts always wait: age-restricted gifts, gifts with unquoted shipping, and recipient photos that need sourcing or approval. The rule applies to people as they are added; turning it on doesn't approve people already waiting. Monthly budgets count calendar months in UTC.

`get_campaign` and `list_campaigns` return the rule as `autoApprove: {enabled, perGiftMaxCents, enabledBy, enabledAt}`. Agents, API keys and sign-in connections can read it but never turn it on or change it. Any signed-in teammate can lower the limits or turn it off. If the person who turned it on stops being an owner or admin, auto-approve turns off.

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

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

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

## Repeats and idempotency

- **Creating a campaign.** Send an `idempotencyKey` (8–150 characters, unique within the workspace, no expiry). The same key with the same body returns the same campaign. The same key with a different body returns `409 IDEMPOTENCY_CONFLICT`. Without a key, list recent campaigns before creating again after a timeout.
- **Adding to a playbook.** A playbook matches the person against its own list, in this order: `sourceSystem` + `externalId` (when both are sent, nothing else is used), then `recipientId`, then an `email` that matches exactly one person, then `profileUrl`. A match returns `200` with `alreadyQueued: true` and the person's current `approval`. The fields you sent are ignored; the saved details don't change, and nothing is charged twice.
- **A list the user pastes or uploads.** It has no CRM record IDs, so make stable ones: set `sourceSystem` to where the list came from (for example `"manual"`, or the event's name such as `"webinar-2026-10"`) and `externalId` to a value that stays the same for that person in that list, such as their LinkedIn profile slug (`jamie-chen-123` from `https://www.linkedin.com/in/jamie-chen-123`) or the row number. Send the same values on every retry, and never invent an ID the list doesn't support.
- **Adding to a batch.** `add_recipients` skips people already on the campaign by the same keys; send the campaign's current `expectedPolicyVersion`.
- **One identity per workspace.** GTM Swag gives each person one `recipientId` across campaigns, from `sourceSystem` + `externalId` or from `email`. A person who already received a gift from another campaign is a repeat: they wait with `repeat_recipient` (or the queue blocker "Gifted before: approve the repeat") until an owner or admin approves.
- **The same contact later, for example at a new company.** A playbook gifts each person once: the same `sourceSystem` and `externalId` returns `alreadyQueued: true`. To gift them again, add them to a different playbook, where they count as a repeat and wait for a person. Don't change `externalId` to get around this.

## Gift links

A gift link is a private URL for one recipient. It shows the sender's message and lets the recipient confirm a US shipping address or decline the gift. (Older docs and fields also say claim link, private link or confirmation link.)

- **When a link exists.** Only for a person added with `addressMode: "confirm"`, once their gift exists: after a person or auto-approve approves a playbook gift, or after a batch campaign is paid and its gifts are prepared. A person added with `addressMode: "known"` ships to that address and has no link. GTM Swag never emails the link; the customer sends it in their own outreach.
- **Get one link.** `get_gift_link_records({campaignId, recipientIds})`, REST `GET /api/v1/campaigns/CAMPAIGN_ID/distribution?recipientIds=RECIPIENT_ID`. Select by `recipientIds` (from the add response), by `giftIds` (a gift ID is the same as its `sendId`), or with `search`, a case-insensitive match on name, email, company, `externalId`, `sourceSystem` or `profileUrl`. Up to 500 IDs, comma-separated on REST; pages of up to 100 with `nextCursor`. In chat, return the one `gift_link` and `gift_expires_at` the user asked for.
- **Get many links.** In a conversation, show up to 25 links as a table (name, link, expiry). For more, export a CSV: `export_gift_links({campaignId, scope})` with `scope` `"all"`, `"selection"` (`recipientIds` or `giftIds`) or `"filtered"` (`search` or `availability`), then `download_gift_link_export({exportId})`, which returns the file as text, `{csv, filename}`, for you to hand to the user as a file. Over REST, `POST /api/v1/campaigns/CAMPAIGN_ID/distribution/exports`, then `GET` its `downloadUrl` with the same key; the URL expires after 24 hours and isn't a public link, so a person can't open it without those credentials. A teammate can also export the same CSV from the campaign's page in GTM Swag. By default the CSV holds only ready links; `includeUnavailable: true` adds the other rows with blank links.
- **Records use snake_case**: `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`, `review_url`. The response is `{version, records, nextCursor, counts: {total, ready, unavailable}, sourceImports}`. An empty `gift_id` means no gift exists for that person yet.
- **Expiry.** A link expires 14 days after its gift is created, or 14 days after a regift, whether or not anyone opened it. Reading, copying or exporting never renews it, so approve or pay for gifts close to when the outreach goes out: a LinkedIn connection request, for example, can take longer than 14 days to be accepted. Expiry doesn't cancel the gift or return its cost on its own. While the gift is unused, even after its link expired, it can be regifted to the same person or someone else, which issues a fresh 14-day link (`regift_gift_link` with the user's go-ahead, or in the web app), or an owner or admin can request a refund in the web app.
- **Access.** Gift-link reads need a workspace key or a connection with `gifts:links:read`, which the user grants separately; ChatGPT also needs `gifts:read`.

| `availability` | Meaning | What to do |
| --- | --- | --- |
| `ready` | `gift_link` is the URL, `gift_expires_at` its expiry | Return or export it |
| `awaiting_approval` | The campaign is a draft, or checkout isn't paid (`availability_reason` `awaiting_approval`, `awaiting_payment` or `funding_pending`) | Give the user the review URL |
| `preparing_links` | With a `gift_id`, the gift exists and its link is being created. Without one, the gift doesn't exist yet: in a playbook the person is still waiting for approval (or for auto-approve to finish); in a paid batch it is still being prepared. | With a `gift_id`, check again in 1–5 minutes. Without one in a playbook, the person needs approval first: wait for `send.created`, or check `list_queue`, before reading again |
| `unavailable` | No usable link; `availability_reason` says why: `known_address`, `claimed`, `declined`, `expired`, `revoked`, `cancelled`, `refunded`, `live_addressless_unavailable`, a shipping status such as `shipped`, or a payment hold | Explain the reason; never create another gift to get a link |

Write a link only where its recipient will receive it, such as a field on that contact's CRM record that feeds your own outreach. Keep links out of shared views, public pages, logs, analytics and generic webhooks; anyone holding a link can use it. Confirm the destination accepted the write before reporting success. Reading or exporting a link doesn't show that anyone opened it.

## Tracking and webhooks

**Poll events.** `list_tracking_events({campaignId?, externalId?, sendId?, status?, type?, limit?, cursor?})`, REST `GET /api/v1/events`, returns `{items, nextCursor, hasMore}` in order. `type` takes one event type; leave it out to get every type and filter on your side. Store `nextCursor` after every poll, including empty ones, and send it back with the same filters; it never goes back to null. Each event has an `id` to deduplicate on.

| Event | When it fires |
| --- | --- |
| `send.created` | A gift was created, by approval, auto-approve or a paid campaign. Fetch its gift link after this. |
| `send.auto_approved` | Auto-approve approved and paid for a gift |
| `send.gift_link_changed` | A link was regifted, revoked, claimed or declined |
| `send.link_opened` | The recipient first opened this link generation (opens by signed-in teammates don't count); carries `generation` |
| `send.address_confirmed` | The recipient confirmed their address |
| `send.status_changed` | The gift's `status` changed |
| `send.order_changed` | The order moved through review, payment or production |
| `send.tracking_changed` | Carrier or tracking number changed |
| `send.outcome_reported` | Someone recorded an outcome for the gift |
| `playbook.person_waiting` | Webhook only: someone added to a playbook waits for an owner or admin, once per reason |

Each `send.*` event carries `id`, `cursor`, `type`, `timestamp`, `sendId`, `campaignId`, `attribution` (including `externalId`, which defaults to the recipient's `externalId`), `mode`, `status`, `outcome`, `orderState`, `carrier`, `trackingNumber` and `trackingUrl`. Events never include addresses, emails, notes or gift links. `playbook.person_waiting` carries `id`, `type`, `timestamp`, `campaignId`, `recipientId`, `sourceSystem`, `externalId`, `reason` and `approvalUrl`, with no names or emails.

Gift `status` moves from `awaiting_address` (waiting for the recipient to confirm) or `queued` to `in_production`, `shipped` and `delivered`, or ends `cancelled`. `get_tracking({campaignId})` (REST `GET /api/v1/tracking`) returns a paged snapshot of where gifts stand now; continue until `nextCursor` is null.

An open, a delivery or an export is not a reply, a meeting or revenue. Never infer one, and never ask the user to report them.

**Webhooks.** A signed-in owner or admin adds one in Integrations → For developers → Signed webhooks (REST `POST /api/v1/webhooks` with `{url, events}` from a browser session), choosing 1–10 event types or `["all"]`. The signing secret is shown once. Each delivery is the event as JSON, with headers `X-GTM-Swag-Event-Id` and `X-GTM-Swag-Signature: t=UNIX_SECONDS,v1=HEX`, where `v1` is the HMAC-SHA256 of `t + "." + rawBody` keyed with the secret. Delivery is at least once: deduplicate by event ID. A failed delivery (a non-2xx answer, a redirect, or no answer within 10 seconds) is tried up to eight times in all, waiting 1, 2, 4, 8, 16, 32 and 64 minutes between tries; an owner or admin can retry it after that. Verify the signature on the raw body before parsing; the API reference has a Node.js example under "Signed outgoing webhooks".

`record_outcome` (full API-key tool list only) records a reply or meeting only when the user volunteers one.

## Connect

Never paste a key, password or token into a chat. Sign-in connections need no key. For a REST tool or Claude Code, store the key in the tool's secret store or an environment variable such as `GTM_SWAG_KEY`, and send `Authorization: Bearer YOUR_GTM_SWAG_KEY` on every request (plus `Content-Type: application/json` for JSON writes). New users create an account at https://gtmswag.com/login; signing up creates their workspace. A key belongs to one workspace and is created in **Integrations → For developers → Create key**. Test it with `GET /api/v1/campaigns`: a `200` with a `data` list, even an empty one, confirms access.

- **Claude (claude.ai, Claude Desktop, Cowork):** open **Customize → Connectors**, select **+ Add**, then **Add custom connector**. Enter the name GTM Swag and the URL `https://gtmswag.com/mcp`, follow the prompts and select **Add**. Then select **Connect**, sign in to GTM Swag and select **Connect workspace**. On a Team or Enterprise plan, an owner first adds it in **Organization settings → Connectors**; members then connect it from **Customize → Connectors**.
- **ChatGPT:** uses its own endpoint, `https://gtmswag.com/chatgpt/mcp`; follow https://gtmswag.com/guides/chatgpt.md. Its 12 tools search gifts, save and edit one-time drafts, read gift links and CSVs, and track gifts. It has no playbook, queue or balance tools.
- **Claude Code:** `claude mcp add --transport http gtm-swag https://gtmswag.com/mcp`, then run `/mcp` and sign in. Sign-in is the recommended way: there is no key to store. To use a key instead, keep it in an environment variable and reference it from a `.mcp.json` file, which Claude Code expands when it loads (`"headers": {"Authorization": "Bearer ${GTM_SWAG_KEY}"}`; see https://gtmswag.com/guides/connect.md). Don't pass `--header "Authorization: Bearer $GTM_SWAG_KEY"` to `claude mcp add`: your shell expands the variable, so the key itself is saved in Claude Code's settings file.
- **Other MCP clients:** Streamable HTTP at `https://gtmswag.com/mcp`, with OAuth sign-in or a bearer key. The server card is https://gtmswag.com/.well-known/mcp.json.
- **REST tools (Clay, HubSpot, your code):** a workspace key. Guides: https://gtmswag.com/guides/clay.md and https://gtmswag.com/guides/hubspot.md.

**Tool lists.** A key at `/mcp` sees the full API-key list of 46 tools. A key at `https://gtmswag.com/mcp?profile=gifting` sees 31 tools, the same focused list a sign-in connection gets with all three scopes: catalog, campaigns, playbook adds, queue and balance reads, tracking and gift links, without workspace administration or `record_outcome`. A sign-in connection sees the part of that list its scopes allow: `gifts:read` (reads), `gifts:write` (drafts, adds, imports and uploads) and `gifts:links:read` (gift links and CSVs, granted separately). ChatGPT's endpoint has its own 12 tools. The 15 signed-in-only tools (approving, skipping, paying, adding funds, team and webhook changes) are listed only for a person signed in to gtmswag.com in their browser; keys and connections never see them. Read `tools/list` after connecting.

**How long a sign-in connection lasts.** Access tokens last 15 minutes and renew on their own; each renewal keeps the connection for another 30 days, up to one year after the user approved it. Then the user connects again. Workspace keys don't expire until revoked in Integrations.

**Disconnect.** Claude and other sign-in connections: https://gtmswag.com/oauth/disconnect. ChatGPT: https://gtmswag.com/chatgpt/oauth/disconnect. Both end access immediately; saved drafts stay in the workspace. Also remove the connector in the app.

MCP also serves prompts (`start_playbook {goal, budget?}`, `add_people_to_playbook {campaignId}`, `prepare_batch_campaign {goal?}`) and resources (`gtmswag://guide`, this guide, and `gtmswag://catalog/summary`). Protocol details are in the API reference.

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

## One-time batch campaigns

Use a batch for a fixed list of up to 500 people. Ask the user for an explicit list, an attached CSV or a source your session can read; never invent prospects or infer a list.

1. `create_campaign` with `mode: "batch"`, `status: "draft"`, `name`, `productId`, `senderName`, `senderCompany`, `budgetCents` (people × price), `perPersonCents` (the price), `maxSends` (number of people, 1–500), an optional shared `message` and a stable `idempotencyKey`. Keep the returned `id`.
2. `get_campaign({id})` for the current `policyVersion` and list.
3. `add_recipients({id, expectedPolicyVersion, recipients})` (REST `POST /api/v1/campaigns/CAMPAIGN_ID/recipients`). Send batches one at a time, each with the latest version. Skip people already present by `recipientId` or exact `sourceSystem` + `externalId`; names alone never match. Adding people returns a batch campaign to draft and raises its limits to fit.
4. `preview_campaign({id})` and `get_campaign_repeats({id})`. Fix `blockers` with the user's input. `nextAction: "review"` means it is ready for a person; `nextAction: "provide_sender_address"` means a `mode: "live"` campaign needs sender-supplied addresses: give the user `https://gtmswag.com/campaigns/CAMPAIGN_ID` and stop.
5. Give the user `https://gtmswag.com/campaigns/CAMPAIGN_ID?review=1` with the count and total. A signed-in teammate reviews, and an owner or admin pays. After payment, gifts are prepared in the background; then fetch gift links. Payment alone doesn't prove every gift was created: check tracking.

Use `update_campaign({id, expectedPolicyVersion, ...})` for settings; its `recipients` field replaces the whole list, so use `add_recipients` to add. Keys may set `draft`, `paused` or `completed`, never `active`. `manage_recipients` removes unsent people or changes their notes. Later additions need another review and payment for the new people only.

**Notes.** A shared `message` and per-person `recipient.message` each allow 500 characters after expanding `{{firstName}}` (the first word of `name`), `{{name}}`, `{{company}}` and `{{custom}}`. A person known only by `profileUrl` or a source record has no name, so a note with `{{firstName}}` can't be approved for them. `{{custom}}` uses `recipient.customTag` (up to 200 characters; CSV column `custom_tag`). Drafts can be incomplete; approval needs every placeholder filled. Don't silently cut text.

When some people lack a company, leave `{{company}}` out of the shared note, or give those people their own `message`; otherwise their gifts can't be approved (and a playbook add is refused). The API applies no default note: a campaign created without `message` has none, so each person needs their own before approval. The web app's campaign builder pre-fills each gift's suggested note, and many suggested notes use `{{company}}`, so check them against the list.

**Product-specific inputs.**

- `personalizationText`: set `customization.displayText` within its limits before approval. Ask for the text.
- `requiresPortrait`: each person needs `photo: {source: "upload", assetId}` from `upload_artwork` with `purpose: "portrait"` (PNG, JPEG or WebP, at least 256×256; MCP base64 up to 900,000 characters, REST `POST /api/v1/artwork` up to 5 MiB), or `import_recipient_photo` with `{url, rightsConfirmed: true}` for a direct public image the user has rights to. Or request staff sourcing with `photo: {source: "linkedin", profileUrl}`; the customer approves the photo before production. Never assume image rights.
- `requiresArtwork`: upload the logo and set `customization.logoAssetId`, or per-person `assetIds` (up to five).
- `fulfillmentKind: "alcohol"`: set `adultRecipientConfirmed: true` only after the customer confirms the recipient is 21 or older and an adult signature is acceptable. Never infer it from a name or title.

Uploads per workspace: 1,000 stored files and 3,000 MiB, 550 uploads (560 attempts) and 550 photo imports per UTC day.

## Manage existing gifts

These change existing gifts without buying anything; get the user's go-ahead first.

- `customize_gift_link_message` (one link) or `customize_campaign_gift_links` (up to 25 links) changes the welcome message or sender branding on unused links. Send each link's current `generation` and `messageVersion`. It doesn't email anyone or change the printed note.
- `regift_gift_link` gives an unused gift to someone else when its record shows `canRegift: true`. The old URL stops working and a new 14-day link replaces it. After an uncertain response, re-read the link: if its generation advanced and the new recipient is there, it worked; never retry with the advanced generation.
- `revoke_gift_link` stops an unused link at once. It doesn't refund.
- `list_campaign_gift_links({id})` shows each link's state, value, `canRegift`, `canRequestRefund` and refund progress. Refunds are requested by an owner or admin in the web app and reviewed by GTM Swag staff; a refund is done only when the record says refunded.

## Large imports and original-row exports

To keep the user's original CSV columns in the export, call `save_recipient_import` (REST `POST /api/v1/campaigns/CAMPAIGN_ID/distribution/imports`) with only the columns the user chose to keep. Limits: 500 rows per campaign, 100 columns, 10,000 characters per cell and 5 MiB per source CSV. For a payload over the request limit, blank unselected cells, serialize the payload as UTF-8, split it into parts of at most 150,000 bytes, base64 each part, and send them with `upload_gift_source_chunk` (stable `uploadId`, zero-based `partIndex`, `partCount` up to 384, `totalBytes` up to 32 MiB, `data` up to 200,000 characters). Retry identical parts; when `retryAfterMs` is present, resend the last part after it until `complete` is true. Unfinished uploads expire after six hours, and a workspace can stage four uploads or 64 MiB at once (`429 UPLOAD_CAPACITY`). `remove_recipient_source_columns({campaignId, importId, indices})` deletes kept columns; it never removes people or gifts. Exports keep the original headers and order and append `gift_link`, `gift_status` and `gift_expires_at`; cells that look like spreadsheet formulas get a leading apostrophe.

## Workspace and team

`get_workspace_settings` reads sender defaults; an owner or admin key can change them with `update_workspace_settings`. `list_team_members` lists teammates. Inviting, removing and changing roles, webhooks, API keys, workspace switching and account security happen in the web app.
