Back to integrations

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.

Playbook-first loop

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

StepMCP toolREST
1. Choose a giftsearch_gifts, then get_giftGET /catalog
2. Create the playbook as a draftcreate_campaign with mode: "always_on"POST /campaigns
3. A person turns it onGive the user https://gtmswag.com/campaigns/CAMPAIGN_ID?review=1Same
4. Add one person per triggeradd_to_playbookPOST /playbooks/CAMPAIGN_ID/people
5. Hand waiting gifts to a personlist_queue, then give the user each approvalUrlGET /queue
6. Keep the balance fundedget_balance, then give the user topUpUrl when lowBalance is trueGET /balance
7. Share gift linksget_gift_link_records or export_gift_linksGET /campaigns/CAMPAIGN_ID/distribution
8. Tracklist_tracking_eventsGET /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.

ActionMemberAdminOwnerAPI keySign-in connection
Read catalog, campaigns, gifts and trackingYesYesYesYesgifts:read
Create and edit draftsYesYesYesYesgifts:write (drafts only)
Add people (add_to_playbook, add_recipients)YesYesYesYesgifts:write
Pause or complete a campaignYesYesYesYesNo
Turn a playbook on, or activate any campaignYes, signed-inYes, signed-inYes, signed-inNoNo
Approve, skip or unskip a waiting giftNoYes, signed-inYes, signed-inNoNo
Turn on auto-approve, or raise its limits or the monthly budget while it is onNoYes, signed-inYes, signed-inNoNo
Lower auto-approve limits or turn it offYes, signed-inYes, signed-inYes, signed-inNoNo
Read the queue and balanceYesYesYesYesgifts:read, MCP only
Add funds to the balanceNoYes, signed-inYes, signed-inNoNo
Pay a campaign checkout, or request a refundNoYes, signed-inYes, signed-inNoNo
Read gift links and CSV exportsYesYesYesYesgifts:links:read
Create an API keyYes, signed-inYes, signed-inYes, signed-inNoNo
Create or remove a webhookNoYes, signed-inYes, signed-inNoNo
List webhooks; read and retry deliveriesNoYesYesOwner or admin keyNo
Update workspace settingsNoYesYesOwner or admin keyNo
List teammatesYesYesYesYesNo
Invite or remove teammatesNoMembers only, signed-inYes, signed-inNoNo
Change a teammate's roleNoNoYes, signed-inNoNo

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.

CodeHTTPnextActionMeaning and what to do
AUTH_REQUIRED401open_urlThe key or connection is missing, revoked or expired. Ask the user to reconnect; don't retry.
INSUFFICIENT_SCOPE403ask_userThe connection lacks the scope for this tool. Ask the user to reconnect and grant it.
HUMAN_APPROVAL_REQUIRED403open_urlOnly a signed-in person can do this. Give the user url (campaign review or queue) and stop.
TEAM_MANAGER_REQUIRED403stopOnly an owner or admin can do this. Tell the user.
DEMO_LIMITED403stopA demo workspace can't use keys, webhooks, uploads or imports. The user creates an account at https://gtmswag.com/login.
PLAYBOOK_OFF409open_urlThe playbook is a draft or paused. A signed-in teammate turns it on at url; then send the same add again.
PLAYBOOK_BUSY409retry_laterOther people were being added to the same playbook. Nothing was saved. Wait retryAfterMs (250–999 ms) and send the same request.
NOT_ALWAYS_ON409stopThe campaign is a batch or single gift. Use add_recipients, or a playbook.
RECIPIENT_SKIPPED409open_urlAn owner or admin skipped this recipient. Don't re-add them.
APPROVAL_REQUIRED409open_urlPlaybook gifts are created only through approval. Give the user url for an owner or admin.
INSUFFICIENT_BALANCE402open_urlThe balance can't cover the gift. Give the user url (the top-up page).
BUDGET_CAP409open_urlThe playbook's monthly budget is used up. An owner or admin raises it at url, or it waits for next month.
MONTHLY_BUDGET_REQUIRED400ask_userAuto-approve needs a monthly budget on the playbook.
BALANCE_FUNDED409stopThe gift was paid from the balance and needs no checkout.
BALANCE_MISMATCH409stopA balance-paid order's amount changed in review. It waits for GTM Swag staff; the balance is never charged more.
LIVE_ADDRESSLESS_UNAVAILABLE409ask_userWith 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_UNVERIFIED409stopWith 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_CHANGED409reread_and_retryexpectedPolicyVersion is stale. Read the campaign, then retry only what is still missing.
IDEMPOTENCY_CONFLICT409ask_userThe key was already used with different content. Recover the original request; don't rotate the key to retry.
RECIPIENT_IDENTITY_CONFLICT409stopThe IDs you sent (email, profile, source record) belong to different people. Ask the user which record is right.
CHECKOUT_LOCKED409retry_laterAn open or paid checkout locks the campaign. Keep the people in your source and retry later.
MODE_LOCKED409stopA campaign paid at checkout can't become a playbook. Create a new playbook.
CAMPAIGN_COMPLETED409stopThe campaign is completed; it takes no changes.
VALIDATION_ERROR400ask_usererror names the invalid fields. Fix them from the schema and catalog; never truncate a note.
RECIPIENT_MESSAGE_REQUIRED400ask_userThe note is missing, too long, or uses a placeholder this person lacks.
NOT_FOUND404ask_userThe ID doesn't exist in this workspace. Ask which record was meant.
CAPABILITY_RETIRED410stopProspect invitation email and reminders are retired. Return a gift link or CSV for the customer's own outreach.
RATE_LIMIT429retry_laterToo many requests. Wait retryAfterMs, then retry.
INTERNAL_ERROR500stopUnexpected 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

TermMeaning
PlaybookA campaign with mode: "always_on" that gifts one person per trigger
Campaign modealways_on (playbook), batch (a fixed list) or single (one person)
Workspace modetest or live, from get_workflow_guide; see "Status"
Gift modeThe mode copied onto a gift, record or event when it was created; it never changes
Balance modeget_balance reports the balance for the workspace's current mode; money added in one never pays for the other
Billing config modeGET /billing/config reports campaign checkout as "sandbox", "live" or "disabled"; canPay is false for keys
Report modeget_report and GET /reports count test and live gifts separately
Gift / sendOne recipient's prepared gift. sendId, giftId and gift_id are the same ID.
Gift linkThe private URL a recipient uses to confirm their address (also called claim link, private link or confirmation link)
approvalUrlHome in GTM Swag, filtered to one playbook's waiting people (https://gtmswag.com/?queue=CAMPAIGN_ID)
Review URLhttps://gtmswag.com/campaigns/CAMPAIGN_ID?review=1, where a person turns a playbook on or reviews a batch
BalancePrepaid workspace funds in cents that playbook gifts draw from
Auto-approveA playbook rule an owner or admin sets: approve gifts within a per-gift limit, the monthly budget and the balance
policyVersionA campaign's version number. Send it back as expectedPolicyVersion on edits and batch adds; a stale one returns POLICY_CHANGED.
blockersWhat must be fixed before a person can approve
nextActionWhat to do after an error; see "Errors and retries"
Gifting profileThe 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 offerprepaidOffer 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 commitquote_send and commit_send: an older per-gift path. Commit is signed-in only; use campaigns and playbooks instead.

Prices and payment

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.

FieldMeaning
idProduct ID for productId. Never invent one.
priceCents, shippingCentsUSD cents; their sum is the price per gift
priceLabel, shippingLabelDisplay text, for example "Shipping quoted before payment"
availableFalse means the gift can't be ordered now. It doesn't reflect retired.
retiredTrue means archived; never choose it for a new campaign
verifiedTrue 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).
fixedPriceThe price covers the listed personalized configuration; delivery is included unless shippingQuoteRequired is true
estimateIncludesDeliveryThe price is an estimate that already includes US delivery, confirmed when the gift is verified
priceQuoteRequiredThe price isn't set; the gift can't be ordered
shippingQuoteRequiredDelivery isn't priced yet (see "Prices and payment")
leadDaysProduction time estimate, as text; not a delivery date
fulfillmentKindstandard, food or alcohol
requiresPortraitEach person needs a photo (see "One-time batch campaigns")
requiresArtworkThe gift needs a logo or artwork file
personalizationThe personalization options, as text
personalizationTextWhen 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, dietaryNoteDelivery and dietary details to pass on
catalogStatus, giftType, category, occasionGrouping 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 pathInput and result
GET /catalogPublic. Every product, including retired ones; choose retired: false and available: true
GET /workflowWorkspace key or session. {mode, connectorCanSend, approvalRequired, capabilities, instructions, steps, batchSteps, approvalReasons, tracking, recipientRequirements, limits, recovery, reviewUrlTemplate, documentation}
GET /campaigns, GET /campaigns/:idCampaigns 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 /campaignsCreate fields below. Returns 201 and the saved campaign
PATCH /campaigns/:idChanged 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-approvalsSigned-in only. {recipientIds} approves reviewed repeats
POST /campaigns/:id/auto-approveSigned-in only; see "Auto-approve settings"
POST /campaigns/:id/checkoutSigned-in owner or admin. {expectedPolicyVersion, idempotencyKey}; returns the Stripe checkout
POST /campaigns/:id/launch?cursor=0Signed-in only. Prepares up to three paid roster positions per call; returns {sends, errors, nextCursor}
GET /campaigns/:id/trackingTracking 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 pathInput 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/reconcileOwner or admin. {}; re-checks Stripe and returns the checkout
POST /billing/checkouts/:checkoutId/cancelSigned-in owner or admin. {}; expires an unpaid checkout
GET /billing/checkouts/:checkoutId/itemsOwner 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.

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

Manage existing unused gifts

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 pathInput 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/approveSigned-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/skipSigned-in owner or admin. {campaignId, recipientId, reason?} (up to 200 characters); returns {skipped: true}
POST /queue/unskipSigned-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=50Newest first: [{id, kind, cents, sendId, campaignId, note, createdAt}], kind topup, debit, credit or adjustment; limit 1–200
POST /balance/topupSigned-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.

CodeHTTPMeaning
HUMAN_APPROVAL_REQUIRED403A key or connection tried to change it
TEAM_MANAGER_REQUIRED403A member tried to turn it on or raise a limit; members may lower limits or turn it off
MONTHLY_BUDGET_REQUIRED400Turning it on needs a monthly budget
VALIDATION_ERROR400The per-gift limit is missing or above the monthly budget
NOT_ALWAYS_ON409Only playbooks auto-approve
POLICY_CHANGED409expectedPolicyVersion 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.

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 pathMCP tool (standard and ChatGPT)Input and result
GET /campaigns/:id/distributionget_gift_link_recordsrecipientIds, giftIds, search, availability, cursor, limit; returns {version: 1, records, nextCursor, counts, sourceImports}
POST /campaigns/:id/distribution/exportsexport_gift_links{scope, preset?, includeUnavailable?, optionalColumns?, columnPrefix?, recipientIds?, giftIds?, search?, availability?}; returns {id, downloadUrl, filename, columns, mode, counts, excluded, earliestExpiry}
GET /distribution/exports/:iddownload_gift_link_exportexportId; REST returns the CSV as an attachment, the tool returns {csv, filename} as text to hand to the user
POST /campaigns/:id/distribution/importssave_recipient_import{importId, headers, retainedColumnIndices, rows}; returns the import summary
POST /campaigns/:id/distribution/import-chunksupload_gift_source_chunk{uploadId, partIndex, partCount, totalBytes, data}
DELETE /campaigns/:id/distribution/source-columnsremove_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 pathInput 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/trackingOne 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/:idGifts with status, mode, portrait state and attribution (MCP list_sends, get_send)
GET /campaigns/:id/resultstotalSends, cancelled, delivered, replied, meetings, meetingsHeld, committedCents, costPerMeetingCents, outcomeSource, meetingDenominator (MCP campaign_results)
GET /campaigns/:id/exportCSV 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 pathInput and result
POST /webhooksSigned-in. {url, events}; returns {id, url, events, createdAt, secret}. The secret is shown only here
GET /webhooksActive webhooks, without secrets (MCP list_webhooks)
DELETE /webhooks/:idSigned-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=giftStarts 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 pathInput and result
GET /team/members[{userId, email, name, role, joinedAt}] (MCP list_team_members)
GET /team/invitationsOwner or admin. The latest 100 invitations (MCP list_team_invitations)
POST /team/invitationsSigned-in owner or admin. {email, role} (admin needs the owner); returns {invitation, inviteUrl, emailQueued}
DELETE /team/invitations/:idSigned-in owner or admin. Revokes an unused invitation
PATCH /team/members/:userIdSigned-in owner. {role}, admin or member
DELETE /team/members/:userIdSigned-in owner or admin; the owner can't be removed, and removing an admin needs the owner. Removing a member revokes their keys
GET /workspacesWorkspaces you can reach; a key sees only its own
GET /settings, PATCH /settingsRead 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.

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.

ToolsScopeArguments and notes
get_workflow_guideread{}; mode, playbook steps, batchSteps, approvalReasons, tracking, limits and recovery by code
search_giftsread{query?, useCase?, budgetCents?, recipientCount?, limit?, excludeFood?, excludePortrait?, excludeArtwork?, excludeAlcohol?, requireKnownShipping?}; {useCase, budgetCents, recipientCount, items, nextStep} with estimatedTotalCents and campaignEstimateCents
get_giftread{productId}; every public field of one gift
browse_giftsread{includeRetired?, detail?: "compact" or "full", limit?, cursor?}; limit 1–100
list_campaigns, get_campaignread{mode?} / {id}; includes autoApprove (read-only)
create_campaign, update_campaignwriteCreate fields / {id, expectedPolicyVersion, ...changed}; OAuth creates drafts only
add_to_playbookwrite{id, recipient}; see POST /playbooks/:id/people
add_recipientswrite{id, expectedPolicyVersion, recipients}; an active playbook needs no version and stays on
manage_recipientswrite{id, action, recipientIds?, emails?, expectedPolicyVersion, message?}
preview_campaign, get_campaign_repeatsread{id}
list_queueread{campaignId?, limit?, cursor?}; {items, nextCursor}
get_balanceread{}; {cents, mode, lowBalance, topUpUrl}
list_sends, get_sendread{campaignId?} / {id} (send ID)
get_tracking, list_tracking_eventsreadTracking filters above
campaign_results, get_reportread{id} / {from?, to?, campaignId?, mode?}
get_workspace_settingsread{}
get_gift_link_records, export_gift_links, download_gift_link_export, list_campaign_gift_linkslinksGift-link inputs above; list_campaign_gift_links takes {id}
save_recipient_import, upload_gift_source_chunk, remove_recipient_source_columnswriteSource-import inputs above
upload_artwork, import_recipient_photowrite{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_giftsigned-in{campaignId, recipientId, approveRepeat?} / {campaignId, recipientId, reason?} / {campaignId, recipientId}; owner or admin
create_balance_topupsigned-in{cents, idempotencyKey}; owner or admin; returns {url, sessionId}
approve_campaign_repeats, create_campaign_checkout, cancel_campaign_checkout, launch_campaign, commit_sendsigned-inCampaign review, payment and preparation
invite_team_member, revoke_team_invitation, update_team_member, remove_team_member, create_webhook, remove_webhooksigned-inTeam 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"}}}'