# GTM Swag: full agent reference > Everything an agent needs in one file: the agent gifting guide (https://gtmswag.com/agent-guide.md), the API and MCP reference (https://gtmswag.com/api-docs.md) and the connection, ChatGPT, Clay and HubSpot guides. Machine-readable schemas: https://gtmswag.com/openapi.json and MCP tools/list at https://gtmswag.com/mcp. # 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. --- # 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 See "Who can do what" in the agent guide above. ## Errors and retries See "Errors and retries" in the agent guide above. ## Glossary See "Glossary" in the agent guide above. ## Prices and payment See "Prices and payment" in the agent guide above. ## Choose a gift that can be approved See "Choose a gift that can be approved" in the agent guide above. ## 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: X-GTM-Swag-Signature: t=,v1= ``` 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"}}}' ``` --- # Connect your gifting workflow GTM Swag helps go-to-market teams get meetings by sending personalized, thoughtful gifts to the people your GTM tools name, at moments such as a new role, an event or webinar follow-up, a funding round, a product launch, a closed deal or a renewal. Your tool or AI agent adds people; an owner or admin on your team approves each gift, or sets an auto-approve rule with limits. Web version: https://gtmswag.com/guides/connect You need a GTM Swag account: create one at https://gtmswag.com/login (signing up creates your workspace). Until GTM Swag enables delivery for your workspace, `get_workflow_guide` reports `mode: "test"`: everything works end to end, but no gift ships and no real payment is taken, so don't send those gift links to real prospects. To enable delivery, email eric@betterswag.com (https://gtmswag.com/support). ## Start with a playbook A playbook is an always-on campaign: one gift, one note template and a monthly budget. Each time a trigger fires (a new role, a signup, a closed deal), your tool adds one person, who waits until an owner or admin approves the gift. An owner or admin can instead turn on auto-approve for the playbook with a per-gift limit and a monthly budget: gifts within those limits and the balance are approved and paid from the balance right away, and anything else waits. 1. Choose a gift: MCP `search_gifts`, then `get_gift`; or REST `GET /api/v1/catalog`. 2. Create the playbook: `create_campaign` with `mode: "always_on"`, or `POST /api/v1/campaigns`. It is saved as a draft. 3. A teammate opens `https://gtmswag.com/campaigns/CAMPAIGN_ID?review=1` and selects **Turn on playbook**. An owner or admin may turn on auto-approve there too. 4. On each trigger: `add_to_playbook`, or `POST /api/v1/playbooks/CAMPAIGN_ID/people` with `{"recipient": {...}}`. Read `approval.state`: `waiting` (with a `reason`), `auto_approved`, `approved`, `scheduled` or `skipped`. 5. An owner or admin approves each waiting gift from the `approvalUrl` in the response, or from `list_queue` / `GET /api/v1/queue`. 6. Keep funds in the balance: `get_balance` / `GET /api/v1/balance`. When `lowBalance` is true, or a gift waits with reason `low_balance`, an owner or admin adds funds at `topUpUrl` (**Settings → Billing & invoices**). Use a batch campaign (`mode: "batch"`) for a fixed list and a single gift (`mode: "single"`) for one person. ## Connect an AI agent (MCP) MCP endpoint: `https://gtmswag.com/mcp`. Connect with GTM Swag sign-in (OAuth) or a workspace key. ### Claude (claude.ai, Claude Desktop, Cowork) 1. Open **Customize → Connectors**, select **+ Add**, then **Add custom connector**. 2. Enter the name GTM Swag and the URL `https://gtmswag.com/mcp`, follow the prompts and select **Add**. 3. Select **Connect**, sign in to GTM Swag, check the workspace shown and select **Connect workspace**. 4. In a chat, open the **+** menu, choose **Connectors** and make sure GTM Swag is on. On a Team or Enterprise plan, an owner first adds the connector in **Organization settings → Connectors** (**Add → Custom → Web**); members then find it in **Customize → Connectors** and select **Connect**. To remove it, use **Remove** next to the connector in **Customize → Connectors**. ### ChatGPT ChatGPT uses its own endpoint, `https://gtmswag.com/chatgpt/mcp`, and its own tool set (no playbooks). Follow https://gtmswag.com/guides/chatgpt.md. ### Claude Code ```sh claude mcp add --transport http gtm-swag https://gtmswag.com/mcp ``` Run `/mcp` in Claude Code and sign in to GTM Swag. Signing in is the recommended way: there is no key to store, and you can end access at any time. To use a workspace key instead, keep it in an environment variable named `GTM_SWAG_KEY`, set from your password manager or shell, never in a chat, and add GTM Swag to your project's `.mcp.json`. Claude Code expands `${GTM_SWAG_KEY}` when it loads the file, so the key itself is never written there: ```json { "mcpServers": { "gtm-swag": { "type": "http", "url": "https://gtmswag.com/mcp", "headers": { "Authorization": "Bearer ${GTM_SWAG_KEY}" } } } } ``` Avoid `claude mcp add … --header "Authorization: Bearer $GTM_SWAG_KEY"`: your shell expands the variable first, so Claude Code saves the key itself in `~/.claude.json`. ### Other MCP clients Use Streamable HTTP at `https://gtmswag.com/mcp` with OAuth, or send a workspace key from the client's secret settings: ```json { "mcpServers": { "gtm-swag": { "type": "http", "url": "https://gtmswag.com/mcp", "headers": { "Authorization": "Bearer YOUR_GTM_SWAG_KEY" } } } } ``` A key at `https://gtmswag.com/mcp` sees the full API-key list of 46 tools; `https://gtmswag.com/mcp?profile=gifting` gives a key the same 31 focused gifting tools that sign-in connections get. Approving, paying, adding funds, and team and webhook changes are never listed for a key or connection: a person does them on gtmswag.com. First prompt: "Use GTM Swag to list gifts under $60. Don't create a campaign." The agent should call `get_workflow_guide` and `search_gifts`. ### How long a sign-in connection lasts A sign-in connection stays active while the tool keeps using it. Access tokens last 15 minutes and renew on their own; each renewal keeps the connection for another 30 days, and every connection ends one year after you approved it. Then reconnect and approve the workspace again. Disconnect at any time: Claude and other MCP clients at https://gtmswag.com/oauth/disconnect, ChatGPT at https://gtmswag.com/chatgpt/oauth/disconnect. Access ends immediately. Workspace keys don't expire; revoke them in **Integrations**. ## Connect an HTTP workflow (REST) Create a workspace key in **Integrations → For developers → Create key**. Store it in your tool's secret store (for example a Clay account or a HubSpot secret), never in a chat, a note or public code. Send it on every request: ```sh curl https://gtmswag.com/api/v1/campaigns \ -H "Authorization: Bearer $GTM_SWAG_KEY" ``` A `200` response with a `data` list (even an empty one) confirms access. A key acts with its creator's current role in that workspace. JSON writes also need `Content-Type: application/json`. Success responses are `{"data": ...}`. Errors are `{"error", "code", "nextAction", "url", "retryable", "retryAfterMs"}`; `nextAction` says what to do next. - Clay: https://gtmswag.com/guides/clay.md - HubSpot workflows: https://gtmswag.com/guides/hubspot.md - OpenAPI 3.1 schema: https://gtmswag.com/openapi.json ## Add a person ```json { "recipient": { "name": "Jamie Chen", "company": "Northstar", "sourceSystem": "your-crm", "externalId": "contact-123", "addressMode": "confirm" } } ``` Each person needs a name, a personal LinkedIn `profileUrl` (`https://www.linkedin.com/in/…`), or both `sourceSystem` and `externalId`. Email and company are optional, unless the playbook's note uses `{{company}}`. Send the same `sourceSystem` and `externalId` on a retry: a repeat returns `alreadyQueued: true` instead of a second gift. For a list without CRM IDs, use the list's name as `sourceSystem` and a value that stays the same per person, such as the LinkedIn profile slug, as `externalId`. When your source has a US shipping address, send `addressMode: "known"` with `address`, `city`, `state` (a two-letter code or the full name) and `zip` (`country` defaults to `US`). With `addressMode: "confirm"`, the recipient confirms their own address through a private gift link that you send in your outreach; once delivery is enabled for your workspace, gifts need the address from you instead. Without auto-approve, adding a person never charges or ships anything. With it, a gift within the limits an owner or admin set is paid from the balance; a repeat never approves or charges twice. A paused or draft playbook refuses with `409 PLAYBOOK_OFF`; a teammate turns it on at the returned `url`. Change a playbook's settings in GTM Swag: an edit made with a key returns it to draft. ## What needs a person Keys and sign-in connections can read, prepare drafts and add people. They can't turn a playbook on, approve or skip gifts, pay, add funds, turn on or change auto-approve, manage the team or set up webhooks. Hand the returned URL to a person instead. Any signed-in teammate can turn a playbook on; approving, skipping, paying and adding funds need an owner or admin. Only an auto-approve rule an owner or admin set approves gifts without a person, within its limits. ## Track what happens Poll `GET /api/v1/events` (MCP `list_tracking_events`) and keep the returned `nextCursor`. `send.created` means a gift exists (fetch its link then), `send.link_opened` means the recipient opened their gift link, and `send.auto_approved` means auto-approve approved a gift. Status and delivery events follow in the same feed. An owner or admin can also add a signed webhook in **Integrations → For developers → Signed webhooks**; webhooks can also receive `playbook.person_waiting` when someone waits for approval. ## Troubleshooting - `401 AUTH_REQUIRED`: the key is missing, revoked or for another workspace, or the sign-in connection expired. - `403 HUMAN_APPROVAL_REQUIRED`: a signed-in person must do this; hand the returned `url` to them. - `409 PLAYBOOK_OFF`: the playbook is paused or still a draft, for example after an edit made with a key. - `409 PLAYBOOK_BUSY`: other people were being added at the same moment; nothing was saved. Retry after `retryAfterMs` (under a second). - `409 NOT_ALWAYS_ON`: use `POST /api/v1/campaigns/CAMPAIGN_ID/recipients` for batch campaigns. - `400 RECIPIENT_MESSAGE_REQUIRED`: the note uses a placeholder, such as `{{company}}`, that this person lacks. - `429 RATE_LIMIT`: the workspace allows 240 requests per minute; wait `retryAfterMs`, then retry. Full reference: https://gtmswag.com/api-docs.md. Agent guide: https://gtmswag.com/agent-guide.md --- # Use GTM Swag in ChatGPT GTM Swag helps go-to-market teams in the US get meetings by sending personalized, thoughtful physical gifts. In ChatGPT you can compare real gifts for a prospect or customer and the moment (a new role, an event follow-up, a funding round, a launch, a closed deal or a renewal), save a campaign draft for your team to review, and later copy gift links and check delivery. ChatGPT never pays, approves spending or sends a gift: a person on your team does that on gtmswag.com. Web version: https://gtmswag.com/guides/chatgpt ## What you need - A GTM Swag account. New here? Create one at https://gtmswag.com/login; signing up creates your workspace. - A ChatGPT account that can add custom MCP servers. On a ChatGPT Business, Enterprise or Edu workspace, an admin may need to allow this first. - No API key. ChatGPT connects by asking you to sign in to GTM Swag. Never paste a key or password into a chat. Gift search works without connecting an account. Connecting is needed to save drafts and read your gifts. ## Connect ChatGPT 1. **Check that ChatGPT can add custom servers.** Custom servers that aren't in ChatGPT's app directory need ChatGPT's developer mode or your workspace's permission. Developer mode is a ChatGPT setting that lets you add an app by its server address. If the steps below don't show **Add custom MCP server**, turn on developer mode in ChatGPT's settings, or ask your ChatGPT workspace admin to allow custom servers. 2. **Add GTM Swag.** In ChatGPT, open **Plugins**, select the **+** button and choose **Add custom MCP server**. Enter the name GTM Swag and the server URL `https://gtmswag.com/chatgpt/mcp`. Choose **OAuth** for authentication, review the warning, select **I understand and want to continue**, then **Create as a plugin**. 3. **Sign in to GTM Swag.** When ChatGPT opens GTM Swag, sign in, check that the workspace shown is the right one, review the permissions and select **Connect workspace**. Connecting doesn't approve any purchase. 4. **Use it in a chat.** Start a new conversation, type `@` and pick GTM Swag. Try: "Find three gifts under $100 for a US prospect's product launch. Explain why each fits and what still needs review." To connect a different workspace, switch workspaces on gtmswag.com first, then connect again. ## What you're allowing The sign-in page lists what ChatGPT asks for: - **View gift plans and delivery progress** (`gifts:read`). - **Create and update gift drafts** (`gifts:write`). - **Retrieve private gift links and download recipient CSVs** (`gifts:links:read`). A gift link lets whoever holds it claim the gift, so share each one only with its recipient. Connections made before this permission existed don't have it; to add it, disconnect (below) and connect again. Recipient details, notes and research you supply are saved with your draft. GTM Swag doesn't verify research you paste in. ## What ChatGPT can do ChatGPT's endpoint, `https://gtmswag.com/chatgpt/mcp`, has 12 tools. ChatGPT tool calls are limited to 120 per minute per workspace. | Tool | What it does | Permission | | --- | --- | --- | | `search_gifts` | Finds gifts for a goal and budget, with photos, prices and what you'd need to supply. Works without signing in. | None | | `get_gifting_workspace` | Reads your sender details, the workspace's delivery status (`mode`) and your ten most recent campaigns | `gifts:read` | | `prepare_gift` | Saves a one-time campaign draft for 1–500 people | `gifts:write` | | `get_gift_plan`, `update_gift_plan` | Reads and edits a saved draft | `gifts:read`, `gifts:write` | | `track_gifts` | Shows where gifts stand: waiting for an address, in production, shipped or delivered | `gifts:read` | | `get_gift_link_records` | Returns recipients' private gift links and their expiry | `gifts:links:read` | | `export_gift_links`, `download_gift_link_export` | Makes a CSV of gift links for your own outreach tool | `gifts:links:read` | | `save_recipient_import`, `upload_gift_source_chunk`, `remove_recipient_source_columns` | Keeps chosen columns from your original contact list for the CSV | `gifts:write` | Every tool except `search_gifts` also needs `gifts:read`. ### Tool arguments Money is whole US cents. Recipients use the same fields as the REST API (https://gtmswag.com/api-docs.md, "Recipient fields"): each needs a `name`, a personal LinkedIn `profileUrl` (`https://www.linkedin.com/in/…`) or `sourceSystem` plus `externalId`; `email`, `company`, `title` and a per-person `message` are optional; `addressMode` is `confirm` (the default) or `known` with `address`, `city`, `state` (a two-letter code or the full state name) and `zip`. - **`search_gifts`**: `query` (a gift type or theme, up to 200 characters), `occasion` (up to 120), `interests` (up to 8 supplied interests), `budgetCents` (per person, 1–1,000,000; 10000, or $100, when left out, and the result then says `budgetWasDefaulted: true`), `recipientCount` (1–500, for campaign estimates), `useCase` (`general`, `cold_outreach`, `company_milestone`, `new_role`, `event_follow_up`, `deal_reengagement`, `customer_thanks` or `team_recognition`), `limit` (1–12, default 6), `country` (`US`; other countries return no gifts), and the filters `excludeFood`, `excludePortrait`, `excludeArtwork` and `requireKnownShipping`. Alcohol and tobacco gifts are never offered in ChatGPT. - **`get_gifting_workspace`**: no arguments. Returns `mode`, `senderName`, `senderCompany` and `recentPlans` (`id`, `name`, `status`, `policyVersion`, `createdAt`). - **`prepare_gift`**: `name`, `productId`, `perPersonBudgetCents` (1–1,000,000, at least the gift's price plus listed shipping), `senderName`, `senderCompany`, `recipients` (1–500), `idempotencyKey` (8–120 characters, the same on every retry of this draft), and optional `message` (the printed note, up to 500 characters), `customization` (up to eight text fields, such as `displayText` for lettering), `research` (up to three `{fact, url}` items, each fact up to 160 characters with a public HTTPS URL) and `externalId` (a CRM reference for the campaign). Returns the draft with its estimate, `blockers`, `nextAction`, `reviewUrl` and any `prepaidOffer`. - **`get_gift_plan`**: `id` (the campaign, `cmp_…`). - **`update_gift_plan`**: `id`, `expectedPolicyVersion` (from `get_gift_plan`), and at least one of `name`, `message` (replaces the note for everyone not yet sent), `customization`, `recipients` (replaces the whole list, 1–500) and `perPersonBudgetCents`. - **`track_gifts`**: optional `campaignId`, `sendId`, `externalId`, `status` (`awaiting_address`, `queued`, `in_production`, `shipped`, `delivered` or `cancelled`), `limit` (1–100, default 50) and `cursor`. - **`get_gift_link_records`**: `campaignId`, and optional `recipientIds` or `giftIds` (up to 500), `search` (name, email, company or CRM ID), `availability` (`ready`, `awaiting_approval`, `preparing_links` or `unavailable`), `limit` (1–100, default 50) and `cursor`. - **`export_gift_links`**: `campaignId`, `scope` (`all`, `selection` with IDs, or `filtered` with `search` or `availability`), and optional `preset` (`original` or `compact`), `includeUnavailable`, `optionalColumns` and `columnPrefix`. Returns the export `id`, counts, exclusions and the earliest expiry. - **`download_gift_link_export`**: `exportId` (the export's `id`). Returns `{csv, filename}` as text. - **`save_recipient_import`**, **`upload_gift_source_chunk`**, **`remove_recipient_source_columns`**: `campaignId` plus the source-import fields in the API reference ("Gift links"). ### What ChatGPT can't do ChatGPT can't pay, approve spending, add funds, send gifts, email recipients, issue refunds, regift or revoke links, change your team, set up webhooks or record replies or meetings. It has no always-on playbooks, approval queue, balance or auto-approve: to gift each person a trigger names, use Claude or another MCP client (https://gtmswag.com/guides/connect.md) or a Clay or HubSpot workflow. It has no `get_workflow_guide` either; `get_gifting_workspace` reports the workspace's `mode` instead. ## How a draft becomes a gift 1. You choose a gift and give ChatGPT each recipient's name, LinkedIn profile or CRM record ID, plus your note. Email and company are optional. If you don't know a shipping address, the recipient confirms their own US address through a private gift link. 2. ChatGPT saves the draft and gives you its review link. Nothing is charged or sent. 3. On gtmswag.com, a teammate checks the people, notes and final price, and an owner or admin pays by card. 4. Back in ChatGPT, ask for gift links, and send them through your own email or sequence. ChatGPT shows up to 25 links as a table; for more, it makes a CSV, which it returns as text you can save as a file (a teammate can also export it from the campaign's page on gtmswag.com). Links expire 14 days after the gift is created, opened or not, so have your team approve close to your outreach. An unused gift whose link expired can be regifted on gtmswag.com for a fresh link. **Prices.** Prices are per gift in US dollars and include listed shipping. The total you approve includes delivery and applicable sales tax; nothing is added at payment. Some gifts show "Shipping quoted before payment": their delivery cost is confirmed before ordering and isn't in the estimate yet. Recipients never pay. **Before delivery is turned on.** Until GTM Swag enables delivery for your workspace, everything works end to end but no gift ships and no real payment is taken, so don't send those gift links to real prospects. To enable delivery, email eric@betterswag.com. Once it is on, only verified gifts can be ordered, and each gift needs a shipping address you supply unless the draft shows a prepaid offer. If a draft shows `live_addressless_unavailable`, open the draft's edit link, add the US addresses and save. ## Disconnect Open https://gtmswag.com/chatgpt/oauth/disconnect, check the workspace and select **Disconnect ChatGPT**. ChatGPT loses access to that workspace at once; saved drafts stay on gtmswag.com. Then remove GTM Swag from ChatGPT's **Plugins** as well. A connection also ends on its own after 30 days without use, and one year after you approved it. Help: https://gtmswag.com/support · Privacy: https://gtmswag.com/privacy · Terms: https://gtmswag.com/terms --- # Gift people from a Clay table GTM Swag helps go-to-market teams get meetings by sending personalized, thoughtful gifts. Each row that reaches a Clay HTTP API column adds one person to a GTM Swag playbook, for example when Clay finds a new role, a funding round or an event attendee. An owner or admin on your team approves each gift in GTM Swag, or turns on auto-approve so gifts within limits they set are approved right away. Clay can then fetch each gift link and record when the recipient opens it. ## Before you start 1. **A GTM Swag account.** Create one at https://gtmswag.com/login if you don't have one. 2. **A playbook that is on.** In GTM Swag, open **Integrations** and choose Clay. Pick a playbook (an always-on campaign) or create one there, then a teammate selects **Turn on playbook**. The page fills in the endpoint and body below with its campaign ID, which is also in the playbook URL: `https://gtmswag.com/campaigns/CAMPAIGN_ID`. If the note uses `{{company}}`, every row needs a company. 3. **Funds and an approver.** Playbook gifts are paid from the workspace balance. An owner or admin adds funds in **Settings → Billing & invoices**, and either approves each gift or turns on auto-approve on the playbook's page. 4. **A workspace key.** Create one in **Integrations → For developers → Create key** in GTM Swag. In Clay, add an **HTTP API (Headers)** account with header name `Authorization` and value `Bearer YOUR_GTM_SWAG_KEY`. Keep the key only in that account. 5. **Delivery status.** Until GTM Swag enables delivery for your workspace, everything works end to end, but no gift ships and no real payment is taken: try it with your own rows, not real prospects. To enable delivery, email eric@betterswag.com. Once delivery is enabled, each gift needs a US shipping address from your table (`addressMode: "known"`); rows without one wait in the queue with "Needs a shipping address". The balance is kept separately once delivery is enabled, so add funds again then. ## Add the HTTP API column In your Clay table, choose **Add enrichment → HTTP API → Configure**: - Method: `POST` - Endpoint: `https://gtmswag.com/api/v1/playbooks/CAMPAIGN_ID/people` - Account: the HTTP API (Headers) account from above - Headers: `Content-Type: application/json` - Body (insert Clay columns where shown, using `/` to pick a column): ```json { "recipient": { "name": "{{Full Name}}", "company": "{{Company Name}}", "title": "{{Job Title}}", "sourceSystem": "clay", "externalId": "{{Row ID}}", "addressMode": "confirm" } } ``` `sourceSystem: "clay"` with the Clay **Row ID** as `externalId` makes the call safe to rerun: the same row returns `alreadyQueued: true` instead of a second gift. Leave out fields you don't have; `email` and `company` are optional. When the row has a US shipping address, send `"addressMode": "known"` with `address`, `city`, `state` (a two-letter code like `TX` or the full name like `Texas`) and `zip`; `country` defaults to `US`. A `profileUrl` must be a personal `https://www.linkedin.com/in/…` profile, not a Sales Navigator or company link. Don't add other fields: unknown fields are refused with `400 VALIDATION_ERROR`. Keep the playbook's settings in GTM Swag: an edit made with a key that changes the playbook (its gift, note or limits) returns it to draft, and every row then fails with `409 PLAYBOOK_OFF` until a teammate turns it on again. Resending the same values changes nothing. Run the column on one row first. A `200` response contains: ```json { "data": { "queued": true, "recipientId": "recipient_0123456789abcdef0123456789abcdef", "alreadyQueued": false, "approvalUrl": "https://gtmswag.com/?queue=CAMPAIGN_ID", "approval": { "state": "waiting", "reason": "auto_approve_off" } } } ``` Map `data.recipientId`, `data.approval.state` and `data.approvalUrl` to columns. `approval.state` is `waiting` (with a `reason`), `auto_approved` or `approved` (the gift exists; `sendId` names it), `scheduled` (auto-approve finishes it within minutes) or `skipped`. Without auto-approve, adding a person never charges or ships anything; the gift waits for approval. Rerunning a row never approves or charges twice. **Large tables.** The workspace allows 240 requests per minute across all tools. A row that fails with `409 PLAYBOOK_BUSY` (other rows were being added to the same playbook at that moment) or `429 RATE_LIMIT` saved nothing: rerun those rows. Reruns are always safe. ## Approve An owner or admin opens `approvalUrl` (Home in GTM Swag, filtered to the playbook) and approves or skips each person. Approval charges the workspace balance. ### Auto-approve (optional) A workspace owner or admin can turn on **auto-approve** on the playbook's page in GTM Swag, with a per-gift limit and a monthly budget. Then each row whose gift fits both limits and the balance is approved right away and paid from the balance, and the response says `"approval": {"state": "auto_approved", "sendId": "send_..."}`. Anything else waits for a person with a reason, for example `over_gift_limit`, `monthly_cap`, `low_balance` (add funds in **Settings → Billing & invoices**) or `shipping_quote` (the gift's delivery isn't priced yet, so it always waits). Only a signed-in owner or admin can turn it on or raise its limits; a workspace key can't. Adding funds, raising a limit or turning auto-approve on doesn't re-check rows already waiting. Rerun those rows after the change: with auto-approve on, the rerun evaluates the rule again and can approve and charge the gift, within its limits, and it never creates a second gift. Without auto-approve, an owner or admin approves them at `approvalUrl`. ## Get the gift link for your own outreach With `addressMode: "confirm"`, the recipient confirms their own US shipping address through a private gift link. GTM Swag creates that link once the gift is approved and never emails it: you send it through your own outreach. People added with `addressMode: "known"` ship straight to that address and have no link. 1. Wait for the approved gift. Subscribe your webhook (below) to `send.created`, which fires once the gift exists and carries `attribution.externalId` (the Clay Row ID), or poll `GET https://gtmswag.com/api/v1/events?type=send.created`. 2. Fetch the link with a second HTTP API column (method `GET`, the same account): ```sh curl "https://gtmswag.com/api/v1/campaigns/CAMPAIGN_ID/distribution?recipientIds=RECIPIENT_ID" \ -H "Authorization: Bearer YOUR_GTM_SWAG_KEY" ``` Use the `data.recipientId` column as `RECIPIENT_ID`, or send `search=` with the Clay Row ID instead of `recipientIds`. 3. Read `data.records[0]`. When `availability` is `ready`, `gift_link` is the private link and `gift_expires_at` its expiry; map them to columns. `awaiting_approval` or `preparing_links` means there is no link yet: with an empty `gift_id`, the person still waits for approval; with a `gift_id`, the link is being created. Run the column again later. `unavailable` comes with an `availability_reason`, such as `known_address`, `claimed`, `revoked` or `expired`. A gift link belongs to one recipient, expires 14 days after the gift is created (opened or not, so time approvals close to your outreach), and works for whoever holds it. An unused gift whose link expired can be regifted in GTM Swag for a fresh 14-day link. Keep it out of shared views, logs and webhooks. Reading it never buys, sends or renews a gift. For many rows at once, `POST https://gtmswag.com/api/v1/campaigns/CAMPAIGN_ID/distribution/exports` with `{"scope": "all"}` creates a CSV of ready links; download it from the returned `downloadUrl` with the same key. AI agents connected over MCP use `get_gift_link_records` and `export_gift_links`. ## Write link opens back to Clay 1. In Clay, create a table with a **Webhook** source and copy its URL. 2. In GTM Swag, a workspace owner or admin opens **Integrations → For developers → Signed webhooks**, adds that URL and selects `send.link_opened` (add `send.created` for new gifts and `send.status_changed` for delivery updates). Save the signing secret. 3. Each event body includes `type`, `sendId`, `campaignId` and `attribution.externalId`, which is the Clay Row ID you sent. Use a Clay lookup on `attribution.externalId` to update the original row. 4. Optionally select `playbook.person_waiting` too. It fires when a row's person waits for approval and carries `externalId` (the Clay Row ID), `reason` and `approvalUrl`, with no names or emails, so you can show who still needs a person. `send.link_opened` fires once per gift link, the first time the recipient opens it. Opens by signed-in members of your workspace aren't counted. An open is not a reply or a meeting, and GTM Swag doesn't ask you to record either. If you'd rather poll, call `GET https://gtmswag.com/api/v1/events?type=send.link_opened` with the same key and keep the returned `nextCursor` for the next call. `type` takes one event type; leave it out to get every type. ## Errors - `409 PLAYBOOK_OFF`: the playbook is paused or a draft. A teammate turns it on at the returned `url`. - `409 PLAYBOOK_BUSY`: other rows were being added at the same moment; rerun the row. - `409 NOT_ALWAYS_ON`: the campaign ID is a batch campaign; use a playbook. - `400 RECIPIENT_MESSAGE_REQUIRED`: the playbook note uses a placeholder, such as `{{company}}`, that the row lacks. - `401 AUTH_REQUIRED`: check the key in the Clay account; the value must start with `Bearer `. - `429 RATE_LIMIT`: wait a minute, or run fewer rows at a time, then rerun. More: https://gtmswag.com/guides/connect.md and https://gtmswag.com/api-docs.md --- # Gift contacts from a HubSpot workflow GTM Swag helps go-to-market teams get meetings by sending personalized, thoughtful gifts. A HubSpot workflow adds each enrolled contact to a GTM Swag playbook when a good moment happens, such as a new role, a closed deal or a renewal. An owner or admin on your team approves each gift in GTM Swag, or turns on auto-approve so gifts within limits they set are approved right away. You can write the result, and later the gift link, back to the contact. ## Before you start 1. **A GTM Swag account.** Create one at https://gtmswag.com/login if you don't have one. 2. **A playbook that is on.** In GTM Swag, open **Integrations** and choose HubSpot. Pick a playbook (an always-on campaign) or create one there, then a teammate selects **Turn on playbook**. Its campaign ID is in the playbook URL: `https://gtmswag.com/campaigns/CAMPAIGN_ID`. If the note uses `{{company}}`, every contact needs a company. Change the playbook's settings in GTM Swag, not through the API: an edit made with a key (`PATCH /api/v1/campaigns/CAMPAIGN_ID`) returns the playbook to draft, and every add then fails with `409 PLAYBOOK_OFF` until a teammate turns it on again. 3. **Funds and an approver.** Playbook gifts are paid from the workspace balance. An owner or admin adds funds in **Settings → Billing & invoices**, and either approves each gift or turns on auto-approve on the playbook's page. 4. **A workspace key.** Create one in **Integrations → For developers → Create key**. It starts with `gtm_`. Store it only in HubSpot's secrets. 5. **Delivery status.** Until GTM Swag enables delivery for your workspace, everything works end to end, but no gift ships and no real payment is taken: test with your own contacts, not real prospects. To enable delivery, email eric@betterswag.com. Once delivery is enabled, each gift needs a US shipping address you send (`addressMode: "known"`); the custom code below does that when the contact has one. Contacts without one wait in the queue with "Needs a shipping address". The balance is kept separately once delivery is enabled, so add funds again then. ## Build the workflow 1. In HubSpot, create a contact-based workflow. 2. **Enrollment trigger:** the gifting moment, for example **Job title** contains any of `VP of Sales`, `Vice President of Sales`, `Head of Sales`, or **Lifecycle stage** is **Customer**. 3. **Re-enrollment:** leave it off. A playbook gifts each contact once, so a second enrollment can't send a second gift anyway: the add matches the contact by `sourceSystem` and `externalId` and returns its current approval. To retry contacts, use a separate retry workflow (see "Retry a contact"). 4. **Add the person** with a **Custom code** action (recommended, below) or a **Send a webhook** action. 5. Optional: write the results back to the contact, and later fetch the gift link (see "Write the gift link back"). ## Add the person with a Custom code action Custom code can retry a busy playbook, which the **Send a webhook** action can't. Custom code actions need a HubSpot subscription that includes them, and HubSpot stops one after 20 seconds, so the code below gives itself 15. 1. Add a **Custom code** action with language Node.js. 2. Add a secret named `GTM_SWAG_KEY` whose value is the key alone (`gtm_…`, without `Bearer `). 3. Add these properties to include in code: `firstname`, `lastname`, `company`, `email`, `jobtitle`, `hs_object_id`, `address`, `city`, `state`, `zip`. 4. Paste the code, replacing `CAMPAIGN_ID`. 5. Add data outputs (all strings): `recipientId`, `approvalState`, `approvalReason`, `approvalUrl`, `sendId`. ```js const axios = require("axios"); const ADD_URL = "https://gtmswag.com/api/v1/playbooks/CAMPAIGN_ID/people"; // HubSpot stops custom code after 20 seconds; finish well inside that. const BUDGET_MS = 15000; // GTM Swag takes two-letter state codes; HubSpot often stores full names. const STATES = { alabama: "AL", alaska: "AK", arizona: "AZ", arkansas: "AR", california: "CA", colorado: "CO", connecticut: "CT", delaware: "DE", "district of columbia": "DC", florida: "FL", georgia: "GA", hawaii: "HI", idaho: "ID", illinois: "IL", indiana: "IN", iowa: "IA", kansas: "KS", kentucky: "KY", louisiana: "LA", maine: "ME", maryland: "MD", massachusetts: "MA", michigan: "MI", minnesota: "MN", mississippi: "MS", missouri: "MO", montana: "MT", nebraska: "NE", nevada: "NV", "new hampshire": "NH", "new jersey": "NJ", "new mexico": "NM", "new york": "NY", "north carolina": "NC", "north dakota": "ND", ohio: "OH", oklahoma: "OK", oregon: "OR", pennsylvania: "PA", "rhode island": "RI", "south carolina": "SC", "south dakota": "SD", tennessee: "TN", texas: "TX", utah: "UT", vermont: "VT", virginia: "VA", washington: "WA", "west virginia": "WV", wisconsin: "WI", wyoming: "WY", }; const CODES = new Set(Object.values(STATES)); const stateCode = (value) => { const text = String(value || "").trim().replace(/\s+/g, " "); const code = STATES[text.toLowerCase()] || text.toUpperCase(); return CODES.has(code) ? code : ""; }; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); exports.main = async (event, callback) => { const started = Date.now(); const contact = event.inputFields; const recipient = { name: [contact.firstname, contact.lastname].filter(Boolean).join(" "), sourceSystem: "hubspot", externalId: String(contact.hs_object_id), addressMode: "confirm", }; if (contact.company) recipient.company = contact.company; if (contact.email) recipient.email = contact.email; if (contact.jobtitle) recipient.title = contact.jobtitle; // A complete US address ships straight to it. const state = stateCode(contact.state); const zip = String(contact.zip || "").trim(); if (contact.address && contact.city && state && /^\d{5}(-\d{4})?$/.test(zip)) Object.assign(recipient, { addressMode: "known", address: contact.address, city: contact.city, state, zip, country: "US", }); for (;;) { const left = BUDGET_MS - (Date.now() - started); if (left < 2000) throw new Error( "TIMEOUT: GTM Swag did not answer in time. Retry this contact; it is never added twice.", ); try { const response = await axios.post( ADD_URL, { recipient }, { headers: { Authorization: `Bearer ${process.env.GTM_SWAG_KEY}` }, timeout: Math.min(5000, left - 500), }, ); const added = response.data.data; return callback({ outputFields: { recipientId: added.recipientId, approvalState: added.approval.state, approvalReason: added.approval.reason || "", approvalUrl: added.approvalUrl, sendId: added.approval.sendId || "", }, }); } catch (error) { const status = error.response?.status; const body = error.response?.data || {}; // Sent but no answer (a timeout or a dropped connection): the add may // have landed. Sending the same contact again is safe. if (!error.response && error.request) continue; // Other contacts were being added at the same moment. Nothing was // saved; wait the short retryAfterMs and send the same request. if (body.code === "PLAYBOOK_BUSY") { await sleep(body.retryAfterMs || 1000); continue; } // Rate limits and server errors: rethrow so HubSpot retries the // action later. Sending the same contact again is safe. if (status === 429 || status >= 500) throw error; throw new Error(`${body.code || "REQUEST_FAILED"}: ${body.error || error.message}`); } } }; ``` `sourceSystem: "hubspot"` with the contact's `hs_object_id` as `externalId` makes every retry safe: the same contact returns `alreadyQueued: true` instead of a second gift. `country` defaults to `US`, the only country GTM Swag ships to; a state that isn't a US state or DC, or an incomplete address, leaves the contact on `addressMode: "confirm"`. **When the action fails.** - A `429` or `5xx` answer is rethrown, so HubSpot retries the action on its own for up to three days, starting a minute later and waiting longer between tries (up to eight hours). - A `TIMEOUT` (no answer within 15 seconds) or an error such as `409 PLAYBOOK_OFF` or `400 VALIDATION_ERROR` fails the action, with the code shown in the workflow's history. HubSpot doesn't retry these. Fix the cause, then retry the contact (below). ## Retry a contact Build a second contact-based workflow, for example "GTM Swag retry", with the same **Custom code** action and outputs (copy the action). Enroll contacts in it by hand from the workflow's **Enroll** button, or with a trigger such as **GTM Swag approval reason** is `low_balance`; turn on re-enrollment in this workflow if you'll retry the same contact more than once. Running the add again never creates a second gift: it returns the contact's current approval. - **The action failed** (`TIMEOUT`, `PLAYBOOK_OFF`, `VALIDATION_ERROR`): fix the cause, then enroll the contact in the retry workflow. - **Waiting with `low_balance`:** adding funds doesn't re-check people already waiting. With auto-approve on, enroll them in the retry workflow after an owner or admin adds funds: the add runs auto-approve again and can approve and charge them, within its limits. Without auto-approve, an owner or admin approves them from the queue at `approvalUrl`; adding again changes nothing. - **Waiting for any other reason** (`auto_approve_off`, `over_gift_limit`, `monthly_cap` and so on): an owner or admin approves at `approvalUrl`. Turning auto-approve on, or raising a limit, doesn't approve people already waiting either; retry them the same way. ## Or add the person with a Send a webhook action - Method: `POST` - Webhook URL: `https://gtmswag.com/api/v1/playbooks/CAMPAIGN_ID/people` - Authentication: API key in the request header, with key name `Authorization` and value `Bearer YOUR_GTM_SWAG_KEY` (here the value includes `Bearer ` and a space). - Request body: customize it so HubSpot sends exactly this JSON, with contact properties in place of the placeholders: ```json { "recipient": { "name": "{{contact.firstname}} {{contact.lastname}}", "company": "{{contact.company}}", "email": "{{contact.email}}", "sourceSystem": "hubspot", "externalId": "{{contact.hs_object_id}}", "addressMode": "confirm" } } ``` GTM Swag accepts only the `recipient` object and refuses unknown fields with `400 VALIDATION_ERROR`, so a default webhook body that sends every property fails. The webhook body can't convert a state name to its code, so leave the address out here. HubSpot doesn't retry a `4xx` webhook response other than `429`, so a contact that hits `409 PLAYBOOK_BUSY` is not added; use the custom code action when several contacts can enroll at once. ## What the response means A successful add returns `200`: ```json { "data": { "queued": true, "recipientId": "recipient_0123456789abcdef0123456789abcdef", "alreadyQueued": false, "approvalUrl": "https://gtmswag.com/?queue=CAMPAIGN_ID", "approval": { "state": "waiting", "reason": "auto_approve_off" } } } ``` - `waiting`: the gift waits for an owner or admin at `approvalUrl` (Home in GTM Swag, filtered to the playbook). `reason` says why: `auto_approve_off`, `over_gift_limit`, `monthly_cap`, `low_balance` (add funds in **Settings → Billing & invoices**), `repeat_recipient`, `photo_review`, `age_restricted`, `shipping_quote` (the gift's delivery isn't priced yet, so it always waits for a person), `email_unverified` or `blocked` (for example, once delivery is enabled, a contact without a shipping address). - `auto_approved`: auto-approve approved the gift and paid it from the balance; `sendId` names it. - `approved`: the contact's gift was already approved. - `scheduled`: auto-approve finishes it in the background, usually within minutes. - `skipped`: a teammate skipped this contact. Without auto-approve, adding a contact never charges or ships anything; approval charges the balance. Adding the same contact again never approves or charges twice. ## Write the gift link back With `addressMode: "confirm"`, the recipient confirms their own US address through a private gift link. GTM Swag creates the link once the gift is approved and never emails it: you send it in your own outreach, for example by merging a contact property into a sequence email. Contacts added with `addressMode: "known"` ship to that address and have no link. A link belongs to one recipient and works for whoever holds it, so keep the property off shared views and reports. It expires 14 days after the gift is created, opened or not, so time approvals close to your outreach; an unused gift whose link expired can be regifted in GTM Swag for a fresh 14-day link. 1. Create contact properties (single-line text): **GTM Swag recipient ID**, **GTM Swag approval state**, **GTM Swag approval reason** and **GTM Swag gift link**. 2. After the custom code action, add an **Edit record** action that sets the first three from the `recipientId`, `approvalState` and `approvalReason` outputs. 3. Add a **Delay** (an hour is enough for auto-approved gifts; waiting gifts need a person first), then a second **Custom code** action with the same secret, the property `gtm_swag_recipient_id` (the internal name of **GTM Swag recipient ID**) and outputs `giftLink`, `giftExpiresAt`, `linkStatus`, `reason`: ```js const axios = require("axios"); exports.main = async (event, callback) => { // A 429 or 5xx answer is thrown as is, so HubSpot retries it. const response = await axios.get( "https://gtmswag.com/api/v1/campaigns/CAMPAIGN_ID/distribution", { params: { recipientIds: event.inputFields.gtm_swag_recipient_id }, headers: { Authorization: `Bearer ${process.env.GTM_SWAG_KEY}` }, timeout: 10000, }, ); const record = response.data.data.records[0] || {}; // No gift_id yet means the contact still waits for approval. const linkStatus = record.availability === "ready" ? "ready" : record.availability === "unavailable" ? "unavailable" : record.gift_id ? "preparing" : "waiting"; callback({ outputFields: { giftLink: linkStatus === "ready" ? record.gift_link : "", giftExpiresAt: record.gift_expires_at || "", linkStatus, reason: record.availability_reason || "", }, }); }; ``` 4. Add an **If/then branch** on `linkStatus`: - `ready`: an **Edit record** action sets **GTM Swag gift link** from `giftLink`. - `preparing`: the gift exists and its link is being created. Delay 10 minutes and run the fetch again. - `waiting`: no gift yet; the contact waits for an owner or admin (or for auto-approve to finish). Delay a day and run the fetch again. - `unavailable`: stop. `reason` says why, such as `known_address`, `claimed`, `expired` or, once delivery is enabled, `live_addressless_unavailable`. HubSpot workflows run top to bottom, so repeat the delay-and-fetch pair a few times (for example after an hour, a day and three days). Reading a link never buys, sends or renews a gift. For many contacts, or a waiting time you can't predict, fetch links outside the workflow instead. A signed webhook for `send.created` (below) fires once each gift exists and carries the HubSpot record ID as `attribution.externalId`; your receiver then reads that gift's link with `GET https://gtmswag.com/api/v1/campaigns/CAMPAIGN_ID/distribution?giftIds=SEND_ID` and updates the contact through HubSpot's API. A scheduled job can poll `GET https://gtmswag.com/api/v1/events?type=send.created` instead, keeping the returned `nextCursor` between runs. To read every ready link at once, `POST https://gtmswag.com/api/v1/campaigns/CAMPAIGN_ID/distribution/exports` with `{"scope": "all"}` returns a CSV at its `downloadUrl` (fetch it with the same key). ## Track An owner or admin can add a signed webhook in **Integrations → For developers → Signed webhooks** for `send.created`, `send.link_opened` and `send.status_changed`. Events carry `attribution.externalId`, the HubSpot record ID you sent, so your receiver can update the contact; verify each delivery's signature (https://gtmswag.com/api-docs.md, "Signed outgoing webhooks"). Add `playbook.person_waiting` to hear when a contact waits for approval: it carries `externalId`, `reason` and `approvalUrl`, with no names or emails. A link open is not a reply or a meeting, and GTM Swag doesn't ask you to record either. ## Errors - `409 PLAYBOOK_OFF`: the playbook is paused or a draft, for example after an edit through the API. A teammate turns it on at the returned `url`. - `409 PLAYBOOK_BUSY`: other contacts were being added at the same moment; nothing was saved. The custom code retries it. - `409 NOT_ALWAYS_ON`: the campaign ID is a batch campaign; use a playbook. - `400 RECIPIENT_MESSAGE_REQUIRED`: the playbook note uses a placeholder, such as `{{company}}`, that the contact lacks. - `400 VALIDATION_ERROR`: a field is invalid, for example a state longer than two letters sent from a webhook body. - `401 AUTH_REQUIRED`: check the secret. Custom code adds `Bearer ` itself; the webhook value must include it. - `429 RATE_LIMIT`: the workspace allows 240 requests per minute across all tools. HubSpot retries the action later. More: https://gtmswag.com/guides/connect.md and https://gtmswag.com/api-docs.md