GTM Swag API and MCP reference
New here? Follow the connection guide. Machine-readable versions: Markdown, OpenAPI 3.1, llms.txt and the MCP server card.
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.
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. SendAuthorization: Bearer YOUR_GTM_SWAG_KEYon every request andContent-Type: application/jsonon 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 useshttps://gtmswag.com/chatgpt/mcp. - Money is integer USD cents. Times are ISO 8601 unless stated.
- Test access with
GET /api/v1/campaigns: a200with adatalist, 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, llms.txt, llms-full.txt and the MCP server card. Connection steps: connection guide.
Playbook-first loop
Use playbooks first (mode: "always_on", one person per trigger), then batches for a fixed list, then single gifts.
| Step | MCP tool | REST |
|---|---|---|
| 1. Choose a gift | search_gifts, then get_gift | GET /catalog |
| 2. Create the playbook as a draft | create_campaign with mode: "always_on" | POST /campaigns |
| 3. A person turns it on | Give the user https://gtmswag.com/campaigns/CAMPAIGN_ID?review=1 | Same |
| 4. Add one person per trigger | add_to_playbook | POST /playbooks/CAMPAIGN_ID/people |
| 5. Hand waiting gifts to a person | list_queue, then give the user each approvalUrl | GET /queue |
| 6. Keep the balance funded | get_balance, then give the user topUpUrl when lowBalance is true | GET /balance |
| 7. Share gift links | get_gift_link_records or export_gift_links | GET /campaigns/CAMPAIGN_ID/distribution |
| 8. Track | list_tracking_events | GET /events |
Agents never approve, skip, pay or add funds. An owner or admin can turn on a playbook's auto-approve in the web app; gifts within its limits are then approved and paid from the balance.
Who can do what
A workspace has one owner, any number of admins and members. An API key acts with its creator's current role, but never does anything marked "signed-in only". A sign-in connection (OAuth) acts within the scopes the user granted. "Signed-in" means a person using gtmswag.com in a browser.
| Action | Member | Admin | Owner | API key | Sign-in connection |
|---|---|---|---|---|---|
| Read catalog, campaigns, gifts and tracking | Yes | Yes | Yes | Yes | gifts:read |
| Create and edit drafts | Yes | Yes | Yes | Yes | gifts:write (drafts only) |
Add people (add_to_playbook, add_recipients) | Yes | Yes | Yes | Yes | gifts:write |
| Pause or complete a campaign | Yes | Yes | Yes | Yes | No |
| Turn a playbook on, or activate any campaign | Yes, signed-in | Yes, signed-in | Yes, signed-in | No | No |
| Approve, skip or unskip a waiting gift | No | Yes, signed-in | Yes, signed-in | No | No |
| Turn on auto-approve, or raise its limits or the monthly budget while it is on | No | Yes, signed-in | Yes, signed-in | No | No |
| Lower auto-approve limits or turn it off | Yes, signed-in | Yes, signed-in | Yes, signed-in | No | No |
| Read the queue and balance | Yes | Yes | Yes | Yes | gifts:read, MCP only |
| Add funds to the balance | No | Yes, signed-in | Yes, signed-in | No | No |
| Pay a campaign checkout, or request a refund | No | Yes, signed-in | Yes, signed-in | No | No |
| Read gift links and CSV exports | Yes | Yes | Yes | Yes | gifts:links:read |
| Create an API key | Yes, signed-in | Yes, signed-in | Yes, signed-in | No | No |
| Create or remove a webhook | No | Yes, signed-in | Yes, signed-in | No | No |
| List webhooks; read and retry deliveries | No | Yes | Yes | Owner or admin key | No |
| Update workspace settings | No | Yes | Yes | Owner or admin key | No |
| List teammates | Yes | Yes | Yes | Yes | No |
| Invite or remove teammates | No | Members only, signed-in | Yes, signed-in | No | No |
| Change a teammate's role | No | No | Yes, signed-in | No | No |
Refusals: a key gets 403 HUMAN_APPROVAL_REQUIRED for a signed-in-only action; a member gets 403 TEAM_MANAGER_REQUIRED for an owner or admin action; a connection gets 403 INSUFFICIENT_SCOPE outside its scopes. A sign-in connection reaches REST only on the gift-link routes (/api/v1/campaigns/CAMPAIGN_ID/distribution… and /api/v1/distribution/exports/…); every other REST route returns 401 AUTH_REQUIRED, so it uses MCP tools instead. A ChatGPT connection has its own smaller tool set, with no playbook, queue or balance tools. The only spending that happens without a person is auto-approve, within limits an owner or admin set.
Errors and retries
REST errors use a non-2xx status. MCP tool errors return HTTP 200 with result.isError: true, so check it on every result; refusals before a tool runs (401, an OAuth 403 or 429) keep their HTTP status. All carry the same body:
{
"error": "Turn this playbook on to add people.",
"code": "PLAYBOOK_OFF",
"nextAction": "open_url",
"url": "https://gtmswag.com/campaigns/CAMPAIGN_ID?review=1",
"retryable": false
}
Follow nextAction: open_url (give the user url), reread_and_retry (read current state, then retry), ask_user (get missing or corrected input), retry_later (wait retryAfterMs, then send the same request) or stop. retryable is true only when retrying can succeed without a person.
| Code | HTTP | nextAction | Meaning and what to do |
|---|---|---|---|
AUTH_REQUIRED | 401 | open_url | The key or connection is missing, revoked or expired. Ask the user to reconnect; don't retry. |
INSUFFICIENT_SCOPE | 403 | ask_user | The connection lacks the scope for this tool. Ask the user to reconnect and grant it. |
HUMAN_APPROVAL_REQUIRED | 403 | open_url | Only a signed-in person can do this. Give the user url (campaign review or queue) and stop. |
TEAM_MANAGER_REQUIRED | 403 | stop | Only an owner or admin can do this. Tell the user. |
DEMO_LIMITED | 403 | stop | A demo workspace can't use keys, webhooks, uploads or imports. The user creates an account at https://gtmswag.com/login. |
PLAYBOOK_OFF | 409 | open_url | The playbook is a draft or paused. A signed-in teammate turns it on at url; then send the same add again. |
PLAYBOOK_BUSY | 409 | retry_later | Other people were being added to the same playbook. Nothing was saved. Wait retryAfterMs (250–999 ms) and send the same request. |
NOT_ALWAYS_ON | 409 | stop | The campaign is a batch or single gift. Use add_recipients, or a playbook. |
RECIPIENT_SKIPPED | 409 | open_url | An owner or admin skipped this recipient. Don't re-add them. |
APPROVAL_REQUIRED | 409 | open_url | Playbook gifts are created only through approval. Give the user url for an owner or admin. |
INSUFFICIENT_BALANCE | 402 | open_url | The balance can't cover the gift. Give the user url (the top-up page). |
BUDGET_CAP | 409 | open_url | The playbook's monthly budget is used up. An owner or admin raises it at url, or it waits for next month. |
MONTHLY_BUDGET_REQUIRED | 400 | ask_user | Auto-approve needs a monthly budget on the playbook. |
BALANCE_FUNDED | 409 | stop | The gift was paid from the balance and needs no checkout. |
BALANCE_MISMATCH | 409 | stop | A balance-paid order's amount changed in review. It waits for GTM Swag staff; the balance is never charged more. |
LIVE_ADDRESSLESS_UNAVAILABLE | 409 | ask_user | With mode: "live", this gift needs a US address from the sender. Keep the draft; with the user's authorization add it as addressMode: "known", or stop. |
PRODUCT_UNVERIFIED | 409 | stop | With mode: "live", the gift isn't verified yet, so it can't be ordered. Choose a gift with verified: true, or wait for GTM Swag to verify it. |
POLICY_CHANGED | 409 | reread_and_retry | expectedPolicyVersion is stale. Read the campaign, then retry only what is still missing. |
IDEMPOTENCY_CONFLICT | 409 | ask_user | The key was already used with different content. Recover the original request; don't rotate the key to retry. |
RECIPIENT_IDENTITY_CONFLICT | 409 | stop | The IDs you sent (email, profile, source record) belong to different people. Ask the user which record is right. |
CHECKOUT_LOCKED | 409 | retry_later | An open or paid checkout locks the campaign. Keep the people in your source and retry later. |
MODE_LOCKED | 409 | stop | A campaign paid at checkout can't become a playbook. Create a new playbook. |
CAMPAIGN_COMPLETED | 409 | stop | The campaign is completed; it takes no changes. |
VALIDATION_ERROR | 400 | ask_user | error names the invalid fields. Fix them from the schema and catalog; never truncate a note. |
RECIPIENT_MESSAGE_REQUIRED | 400 | ask_user | The note is missing, too long, or uses a placeholder this person lacks. |
NOT_FOUND | 404 | ask_user | The ID doesn't exist in this workspace. Ask which record was meant. |
CAPABILITY_RETIRED | 410 | stop | Prospect invitation email and reminders are retired. Return a gift link or CSV for the customer's own outreach. |
RATE_LIMIT | 429 | retry_later | Too many requests. Wait retryAfterMs, then retry. |
INTERNAL_ERROR | 500 | stop | Unexpected failure. After a write, read current state before trying again. |
Any other 5xx on a read returns retry_later; after a write it returns reread_and_retry, because the write may have partly landed.
Rate limits. Each workspace has 240 requests per minute, shared by all its keys, connections and the web app. Every REST request and every MCP HTTP request counts once; artwork uploads and downloads have their own limits. Windows are fixed minutes on the clock. On 429 RATE_LIMIT, wait retryAfterMs (the time left in the current window) and retry; the response also carries a standard Retry-After header in whole seconds, which workflow tools such as HubSpot honor on their own. Daily upload limits reset at UTC midnight, and retryAfterMs says when. ChatGPT tool calls have their own limit of 120 per minute per workspace. Send people one request at a time per playbook, or a list with add_recipients, instead of parallel row writes.
Timeouts and lost responses. add_to_playbook is safe to resend with the same sourceSystem and externalId. Resend create_campaign with the same idempotencyKey and identical body. For any other write, read current state first; payment and approval retries belong to a person.
Glossary
| Term | Meaning |
|---|---|
| Playbook | A campaign with mode: "always_on" that gifts one person per trigger |
Campaign mode | always_on (playbook), batch (a fixed list) or single (one person) |
Workspace mode | test or live, from get_workflow_guide; see "Status" |
Gift mode | The mode copied onto a gift, record or event when it was created; it never changes |
Balance mode | get_balance reports the balance for the workspace's current mode; money added in one never pays for the other |
Billing config mode | GET /billing/config reports campaign checkout as "sandbox", "live" or "disabled"; canPay is false for keys |
Report mode | get_report and GET /reports count test and live gifts separately |
| Gift / send | One recipient's prepared gift. sendId, giftId and gift_id are the same ID. |
| Gift link | The private URL a recipient uses to confirm their address (also called claim link, private link or confirmation link) |
approvalUrl | Home in GTM Swag, filtered to one playbook's waiting people (https://gtmswag.com/?queue=CAMPAIGN_ID) |
| Review URL | https://gtmswag.com/campaigns/CAMPAIGN_ID?review=1, where a person turns a playbook on or reviews a batch |
| Balance | Prepaid workspace funds in cents that playbook gifts draw from |
| Auto-approve | A playbook rule an owner or admin sets: approve gifts within a per-gift limit, the monthly budget and the balance |
policyVersion | A campaign's version number. Send it back as expectedPolicyVersion on edits and batch adds; a stale one returns POLICY_CHANGED. |
blockers | What must be fixed before a person can approve |
nextAction | What to do after an error; see "Errors and retries" |
| Gifting profile | The focused tool list at /mcp?profile=gifting, the same one sign-in connections get; the full key profile at /mcp adds workspace administration tools |
| Prepaid offer | prepaidOffer from preview_campaign on a batch or single campaign: a fixed, tax-inclusive price per recipient, paid at checkout before links work, that lets mode: "live" recipients confirm their own address within its shippingStates. It appears only when GTM Swag has qualified the gift for it, and never applies to playbooks. |
| Legacy quote and commit | quote_send and commit_send: an older per-gift path. Commit is signed-in only; use campaigns and playbooks instead. |
Prices and payment
- Price per gift is the catalog
priceCents + shippingCents, in integer USD cents (search results return it asestimatedTotalCents). 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: truehas no delivery cost in its price yet (shippingCentsis usually 0), so budgets and previews don't include delivery, and auto-approve never approves it: each one waits for a person with reasonshipping_quote. Its final price is confirmed before it can be ordered withmode: "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_balancereports the current one, and funds added while the workspace hadmode: "test"never pay for gifts that ship. - Batch and single campaigns are paid by card through a Stripe checkout that an owner or admin completes on gtmswag.com.
- Nothing is charged when you save a draft, add a person or read anything. Money moves only when an owner or admin approves or pays, or when auto-approve approves a gift within its limits.
- Recipients never pay. No subscription or platform fee is charged; you pay only for gifts.
Choose a gift that can be approved
search_gifts ranks current gifts (not retired, priced and not marked unavailable) within budgetCents per person, with estimatedTotalCents and campaignEstimateCents (recipientCount × price). It doesn't filter on verified: every result can be planned, but with mode: "live" only a verified gift can be ordered. useCase ranks gifts for the goal: general (the default, which infers a goal from query), cold_outreach, company_milestone (a launch, funding round or anniversary), new_role, event_follow_up, deal_reengagement, customer_thanks or team_recognition. Filters: excludeFood, excludePortrait, excludeArtwork, excludeAlcohol and requireKnownShipping. browse_gifts pages the whole catalog (limit up to 100, then nextCursor; detail: "full" returns every public field). The public REST catalog also lists retired gifts for history.
Filter the REST catalog in this order: retired: false first (a retired gift can still show available: true), then available: true and no priceQuoteRequired. Skip any gift with priceCents: 0: it is an unpriced placeholder that can't be ordered. Prefer shippingQuoteRequired: false when the budget must hold exactly or the playbook uses auto-approve. A flag that is absent, such as shippingQuoteRequired, fixedPrice or estimateIncludesDelivery, means false.
| Field | Meaning |
|---|---|
id | Product ID for productId. Never invent one. |
priceCents, shippingCents | USD cents; their sum is the price per gift |
priceLabel, shippingLabel | Display text, for example "Shipping quoted before payment" |
available | False means the gift can't be ordered now. It doesn't reflect retired. |
retired | True means archived; never choose it for a new campaign |
verified | True once GTM Swag has confirmed the supplier, price and delivery. Until then the gift can be planned and tried end to end, and its price is an estimate, but it can't be ordered with mode: "live" (PRODUCT_UNVERIFIED). |
fixedPrice | The price covers the listed personalized configuration; delivery is included unless shippingQuoteRequired is true |
estimateIncludesDelivery | The price is an estimate that already includes US delivery, confirmed when the gift is verified |
priceQuoteRequired | The price isn't set; the gift can't be ordered |
shippingQuoteRequired | Delivery isn't priced yet (see "Prices and payment") |
leadDays | Production time estimate, as text; not a delivery date |
fulfillmentKind | standard, food or alcohol |
requiresPortrait | Each person needs a photo (see "One-time batch campaigns") |
requiresArtwork | The gift needs a logo or artwork file |
personalization | The personalization options, as text |
personalizationText | When present, the lettering limits {label, maxLength, maxLines, lineMaxLength, firstLineRequired?, allowedCharacters?, help}; set customization.displayText within them |
quoteDetails | {configuration, decoration, inputs, proof}: what the price covers, the decoration, what the sender provides, and the proof step |
deliveryNote, dietaryNote | Delivery and dietary details to pass on |
catalogStatus, giftType, category, occasion | Grouping and display fields |
quoteDetails.inputs lists what the sender provides; there is no separate field for each one. Put lettering in customization.displayText, a logo in customization.logoAssetId (an ID from upload_artwork), and anything else in the campaign's message or as extra customization text fields (up to eight fields of 400 characters each). Ask the user for these; never invent them. GTM Swag staff confirm the details with the customer before production.
Campaigns
Paths are relative to /api/v1. :id is a campaign ID (cmp_…) unless stated.
| Method and path | Input and result |
|---|---|
GET /catalog | Public. Every product, including retired ones; choose retired: false and available: true |
GET /workflow | Workspace key or session. {mode, connectorCanSend, approvalRequired, capabilities, instructions, steps, batchSteps, approvalReasons, tracking, recipientRequirements, limits, recovery, reviewUrlTemplate, documentation} |
GET /campaigns, GET /campaigns/:id | Campaigns with mode, status, policyVersion, monthlyBudgetCents, autoApprove (playbooks, once set), requiresPayment, spentCents, sentCount and recipients. Filter the list with ?mode=always_on, batch or single |
POST /campaigns | Create fields below. Returns 201 and the saved campaign |
PATCH /campaigns/:id | Changed fields plus expectedPolicyVersion (required for keys). Content edits return the campaign to draft unless you set paused or completed; keys can't set active |
POST /campaigns/:id/recipients | {recipients, expectedPolicyVersion}. Appends people and raises the limits to fit. A batch returns to draft; an active playbook stays on and needs no version |
POST /campaigns/:id/recipients/bulk | {action: "message" or "remove", recipientIds, expectedPolicyVersion, message?} for 1–500 unsent people (MCP manage_recipients) |
GET /campaigns/:id/preview | {campaignId, policyVersion, recipientCount, unitCents, totalCents, currency, alreadySent, blockers?, fulfillmentRequirements?, nextAction, unavailableReason, prepaidOffer?, checkout?}. No payment or gifts |
GET /campaigns/:id/repeats | [{recipientId, email, name, priorSendId, approved}]: people gifted by another campaign |
POST /campaigns/:id/repeat-approvals | Signed-in only. {recipientIds} approves reviewed repeats |
POST /campaigns/:id/auto-approve | Signed-in only; see "Auto-approve settings" |
POST /campaigns/:id/checkout | Signed-in owner or admin. {expectedPolicyVersion, idempotencyKey}; returns the Stripe checkout |
POST /campaigns/:id/launch?cursor=0 | Signed-in only. Prepares up to three paid roster positions per call; returns {sends, errors, nextCursor} |
GET /campaigns/:id/tracking | Tracking for one campaign (see "Tracking, events and outcomes") |
Create fields
Required: name, productId, senderName, senderCompany. Batch and single campaigns also need budgetCents (1–100,000,000), perPersonCents (1–10,000,000, at most the budget) and maxSends (1–500); for N people use N × price, the price and N. Playbooks (mode: "always_on") default perPersonCents to the gift's price, budgetCents to 100,000,000 and maxSends to 2,000.
Optional: mode (default batch), status (default draft; keys and sign-in connections can't create active), monthlyBudgetCents (caps a playbook's spend per UTC calendar month; required for auto-approve), message (shared note, up to 500 characters after personalization), description, customization (up to eight text fields of 400 characters), meetingUrl, attribution, recipientSource (manual or workflow), recipients (default [], at most 500) and idempotencyKey (8–150 characters). Names and sender fields allow 200 characters.
customization keys GTM Swag reads: displayText (lettering, checked against the gift's personalizationText), logoAssetId (an uploaded logo, added to every person's artwork), giftLinkWelcome (the gift-link welcome, up to 400 characters with {{firstName}} or {{company}}) and giftLinkContactEmail (a sender contact address shown to recipients). Other keys are stored as text for the order.
Recipient fields
Each person needs a name, a personal LinkedIn profileUrl, or sourceSystem plus externalId. Optional: email, company, title, context (research notes, never printed), message (overrides the shared note), customTag (up to 200 characters, for {{custom}}), assetIds (up to five artwork IDs), photo, adultRecipientConfirmed, details (see gift details below), recipientId (to update a known person), importId and importRowId (both or neither). Unknown fields return 400 VALIDATION_ERROR.
addressMode is confirm (default: the recipient confirms their address through a gift link) or known, which needs name, address, city, a US state (a two-letter code such as TX, or the full name such as Texas, which is stored as the code) and a 5- or 9-digit zip. country is US only and defaults to US.
profileUrl is a personal LinkedIn profile, https://www.linkedin.com/in/SLUG, on linkedin.com, www.linkedin.com or a two-letter country subdomain; it is saved as https://www.linkedin.com/in/SLUG. Sales Navigator, company and /pub/ URLs return 400 VALIDATION_ERROR. For a pasted list without CRM IDs, set sourceSystem to the list's origin (such as manual or the event name) and externalId to a value that stays the same for each person, such as the LinkedIn slug or row number.
Notes. Placeholders are {{firstName}} (the first word of name), {{name}}, {{company}} and {{custom}} (customTag). Values are inserted literally, once; nothing is taken from profileUrl, email or the source record. A note whose placeholder has no value for a person blocks approval, and a playbook add returns 400 RECIPIENT_MESSAGE_REQUIRED; drafts may stay incomplete. Every person needs a resolved note of at most 500 characters before approval. The API applies no default note (message defaults to empty); the web app's campaign builder pre-fills a gift's suggested note, which often uses {{company}}. CSV imports read message, personal_message or personal_note, and custom_tag.
Preview. blockers lists what stops approval (missing note, photo, lettering, 21+ confirmation). fulfillmentRequirements lists later steps as {recipientId, recipientName, recipientEmail, kind, message}, where kind is photo_sourcing, address_confirmation or sender_address_required. nextAction is review (ready for a person) or provide_sender_address (mode: "live" needs sender-supplied addresses; give the user https://gtmswag.com/campaigns/CAMPAIGN_ID and stop). The server returns PERSONALIZATION_REQUIRED when approval or checkout is tried without required lettering.
Gift details. Some gifts need a fact about each person, such as their team, a link to their company logo or their patent number. The gift lists these in recipientDetails as {key, label, help, kind, maxLength, required}; kind is text or url (a web address, with or without https://). Put each answer in recipient.details under its key, for example "details": {"team": "Chicago Cubs"}. Drafts may leave them blank; preview lists missing ones in blockers, and approval, checkout and auto-approve stop with RECIPIENT_DETAILS_REQUIRED until every required field is filled. Ask the customer for these facts; don't guess them. CSV imports take one column per detail, named after its key in snake case (company_logo, team).
Versions. Every key-authenticated edit, batch add and checkout sends expectedPolicyVersion from the latest read. A missing version returns 400 VALIDATION_ERROR; a stale one 409 POLICY_CHANGED. Use the returned policyVersion on the next write. An open checkout locks edits until it is cancelled or expires, or until its paid gifts are prepared (409 CHECKOUT_LOCKED).
Checkout and invoices
Batch and single campaigns are paid by card. A signed-in owner or admin starts checkout from the campaign review; keys get canPay: false.
| Method and path | Input and result |
|---|---|
GET /billing/config | {enabled, mode, canPay} |
GET /billing/checkouts?campaignId=&limit=50&before= | Owner or admin. Checkouts with Stripe invoice links; limit 1–100; before is a creation time |
POST /billing/checkouts/:checkoutId/reconcile | Owner or admin. {}; re-checks Stripe and returns the checkout |
POST /billing/checkouts/:checkoutId/cancel | Signed-in owner or admin. {}; expires an unpaid checkout |
GET /billing/checkouts/:checkoutId/items | Owner or admin. Paid items with amount, send ID, status and refund eligibility |
Checkout fields: id, campaignId, state (creating, open, paid, consumed, expired, cancelled), amountCents, recipientCount, remainingCount, launchCursor, currency: "usd", mode, policyVersion, createdAt, expiresAt, and optional paidAt, url, invoiceUrl and invoicePdf. The same idempotencyKey and campaign version return the existing checkout. After payment, the paid list is prepared in the background; a payment return URL never proves payment, and payment doesn't prove every gift was created. Read tracking.
Gift link branding and welcome messages
New links take the campaign's sender name and company, customization.logoAssetId, customization.giftLinkContactEmail and customization.giftLinkWelcome. Edit unused, unexpired links directly:
GET /campaigns/:id/gift-linksreturns each link'slandingMessage,messageVersion,generation, sender fields,contactEmail,logoAssetId,valueCents,canRegift,canRequestRefundand refund progress (MCPlist_campaign_gift_links).POST /sends/:id/gift-link/messagetakes{generation, expectedMessageVersion, message?, branding?}(MCPcustomize_gift_link_message).POST /campaigns/:id/gift-links/messagetakes{message?, branding?, links: [{sendId, generation, expectedMessageVersion}]}for up to 25 links at once, all or nothing (MCPcustomize_campaign_gift_links).brandingis 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/regifttakes{generation, recipient, message}(MCPregift_gift_link). It keeps the original payment, invalidates the old URL and creates a new 14-day link, for an unused gift whose link showscanRegift: truein 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/revoketakes{generation}(MCPrevoke_gift_link) and stops an unused link without a refund.GET /campaigns/:id/gift-link-settingsandGET /campaigns/:id/gift-refundsread history (MCPget_campaign_invitations).POST /sends/:id/gift-link/refundis 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/emailandPATCH /campaigns/:id/gift-link-settingsreturn410 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.
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:
X-GTM-Swag-Event-Id: <event ID>
X-GTM-Swag-Signature: t=<Unix seconds>,v1=<hex HMAC-SHA256(secret, t + "." + rawBody)>
Verify the raw body before parsing, compare in constant time, accept only a short timestamp window and deduplicate by event ID. Delivery is at least once; a retry keeps the body and event ID with a fresh signature. In Node.js:
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:** useserver/discoverinstead ofinitialize. Every request carriesparams._metawithio.modelcontextprotocol/protocolVersion: "2026-07-28"andio.modelcontextprotocol/clientCapabilities: {}(optionalio.modelcontextprotocol/clientInfo: {name, version}), and matchingMCP-Protocol-VersionandMcp-Methodheaders;tools/callalso sendsMcp-Nameequal toparams.name. Complete results includeresultType: "complete"and server information in_meta. - Older versions:
initializewithprotocolVersion,capabilities: {}andclientInfo: {name, version}, thennotifications/initialized, then the negotiatedMCP-Protocol-Versionheader. SendAccept: application/json, text/event-stream. Notifications return202. - Unsupported versions return
400with JSON-RPC error-32022anderror.data.supported; header mismatches return-32020; invalid parameters-32602. Requests with a disallowedOriginare 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:
{
"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:
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:
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"}}}'