---
title: "Create a playbook or campaign draft"
method: POST
path: "/campaigns"
tags: ["Campaigns"]
---

# Create a playbook or campaign draft

`POST /campaigns`

## Request

- Base URL: `https://gtmswag.com/api/v1`
- URL: `https://gtmswag.com/api/v1/campaigns`
- Auth: HTTP bearer

Prefer `mode:"always_on"` (a playbook) with no recipients: limits default from the gift (`budgetCents` 100,000,000, `perPersonCents` the gift and shipping, `maxSends` 2,000). Batch and single campaigns also need `budgetCents`, `perPersonCents` and `maxSends` (at most 500 people). Saves a draft; a signed-in person turns it on at `https://gtmswag.com/campaigns/{id}?review=1`. Keys cannot set `status:"active"` (403 HUMAN_APPROVAL_REQUIRED) or `autoApprove`. Idempotent with `idempotencyKey`: the same key and body return the same campaign (201); the same key with a different body returns 409 IDEMPOTENCY_CONFLICT.

## Request body

- object
  - `recipientSource` 'manual' | 'workflow': Manual roster or external workflow.
  - `mode` 'always_on' | 'batch' | 'single': always_on keeps sending; batch is one roster; single is one gift.
  - `monthlyBudgetCents` integer: Monthly spend limit in USD cents for always-on campaigns.
  - `customization` Customization: displayText: lettering; logoAssetId: artwork; giftLinkContactEmail: public sender contact; giftLinkWelcome: private-link welcome (400 chars, {{firstName}}/{{company}}), separate from the printed note.
  - `name` string, required: Campaign name for review and lookup.
  - `description` string: Internal campaign description.
  - `attribution` Attribution: Reporting references; no secrets/PII.
    - `externalId` string: External record ID (recipient contact ID).
    - `source` string: Source system or list name.
    - `contactId` string: External CRM contact ID.
    - `accountId` string: External CRM account ID.
    - `opportunityId` string: External CRM opportunity ID.
    - `metadata` object: Source labels; no secrets/PII.
  - `productId` string, required: Current catalog gift ID; never invented.
  - `status` 'draft' | 'active' | 'paused' | 'completed': Campaign state; connectors prepare drafts.
  - `budgetCents` integer: Campaign limit in USD cents.
  - `perPersonCents` integer: Per-person USD cents, within budget.
  - `maxSends` integer: Maximum campaign recipient count.
  - `senderName` string, required: Gift sender's name.
  - `senderCompany` string, required: Gift sender's company.
  - `message` string: Printed note and recipient fallback.
  - `meetingUrl` union: An absolute URL, or an empty string for none. Both variants are strings, so the union has no discriminator.
    - ''
    - string, uri
  - `recipients` RecipientInput[]: Distinct recipient identities; email and company are optional.
    - `name` string: Recipient's full name.
    - `recipientId` string: Stable workspace recipient ID; never infer from a name.
    - `sourceSystem` string: Original source system name; pair with externalId for stable matching.
    - `profileUrl` string, uri: Supplied personal profile URL; photo sourcing requires separate permission.
    - `importId` string: Stable customer import ID; reuse with identical retry data.
    - `importRowId` string: Stable source row ID within an import.
    - `photo` union: Portrait upload or LinkedIn sourcing.
      - RecipientPhotoUpload
        - `source` 'upload', required: upload: owned asset; linkedin: staff sourcing.
        - `assetId` string, required: Owned portrait asset_ ID from upload/import.
        - `rightsConfirmed` true: True only with explicit photo-use permission.
      - RecipientPhotoLinkedIn
        - `source` 'linkedin', required: upload: owned asset; linkedin: staff sourcing.
        - `profileUrl` string, uri, required: Supplied personal profile URL; photo sourcing requires separate permission.
    - `adultRecipientConfirmed` boolean: Explicit 21+ and adult-signature confirmation.
    - `assetIds` string[]: Owned artwork asset IDs uploaded to this workspace.
    - `externalId` string: External record ID (recipient contact ID).
    - `email` union: An email address, or an empty string for none. Both variants are strings, so the union has no discriminator.
      - ''
      - string, email
    - `company` string: Recipient company; used for {{company}}.
    - `title` string: Recipient's job title.
    - `context` string: Recipient background from customer.
    - `customTag` string: Value for {{custom}} in this note.
    - `address` string: Street and unit; required when known.
    - `city` string: US city; required when known.
    - `state` string: US state/DC code; required when known.
    - `zip` string: US ZIP/ZIP+4; required when known.
    - `country` 'US': Delivery country; US only.
    - `addressMode` 'known' | 'confirm': known: complete sender-supplied US shipping address. confirm: address unknown; the recipient confirms it through a private gift link where the workspace allows it, including a qualified prepaid offer shown on the campaign preview. Otherwise live address-free gifts are refused with LIVE_ADDRESSLESS_UNAVAILABLE.
    - `message` string: Printed note; blank uses campaign fallback.
    - `details` object: Per-recipient gift details keyed by the gift's recipientDetails keys, such as companyLogo or team. Required ones must be filled before approval.
  - `idempotencyKey` string: Same key for identical retries only.

## Response `201`

Success: `{"data": Campaign}`.

- CampaignResponse: The success body: `{"data": Campaign}`.
  - `data` Campaign, required: A playbook (`mode:"always_on"`) or a batch or single campaign, with its roster, limits and `policyVersion`.
    - `id` string, required
    - `status` 'draft' | 'active' | 'paused' | 'completed', required
    - `mode` 'always_on' | 'batch' | 'single', required
    - `monthlyBudgetCents` integer
    - `autoApprove` AutoApprovePolicy: Auto-approve rule a signed-in owner or admin set in the web app. While enabled, a person added to this active playbook whose gift fits perGiftMaxCents, monthlyBudgetCents and the balance is approved and paid from the balance on enabledBy's behalf. Read-only through keys and connections.
      - `enabled` boolean, required
      - `perGiftMaxCents` integer, required
      - `enabledBy` string
      - `enabledAt` string
    - `policyVersion` integer, required
    - `recipients` Recipient[], required
      - `recipientId` string, required
      - `name` string, required
      - `email` string, required
      - `company` string, required
      - `sourceSystem` string
      - `externalId` string
      - `profileUrl` string
      - `importId` string
      - `importRowId` string
      - `addressMode` 'known' | 'confirm'

## Other responses

- `400`: Bad request: `VALIDATION_ERROR`, `INVALID_REQUEST`, `INVALID_JSON`. Each example says when.
- `401`: Not authenticated: `AUTH_REQUIRED`. no credentials, or an OAuth token on a route that only takes workspace keys; the key is malformed, unknown or revoked.
- `403`: Forbidden: `HUMAN_APPROVAL_REQUIRED`, `ARTWORK_UNAVAILABLE`, `RECIPIENT_SCOPE`. Each example says when.
- `409`: Conflict: `IDEMPOTENCY_CONFLICT`, `GIFT_RETIRED`, `GIFT_UNAVAILABLE`, `PRICE_QUOTE_REQUIRED`, `RECIPIENT_IDENTITY_CONFLICT`, `DUPLICATE_RECIPIENT`. Each example says when.
- `413`: Request too large: `INVALID_REQUEST`. the body exceeds 1 MiB.
- `415`: Unsupported media type: `INVALID_REQUEST`. a write with a body that is not `Content-Type: application/json` (a key request with no body needs no Content-Type).
- `429`: Too many requests: `RATE_LIMIT`. more than 240 requests in the current minute for this workspace (REST and MCP share the limit); wait `retryAfterMs`.
- `default`: Any other refusal or failure, such as 403 CSRF (a cross-origin `Origin` header), 403 HTTPS_REQUIRED or 500 INTERNAL_ERROR, with what to do next.

---

[API](https://skmtc.dev/gtmswag/apis/gtm-swag-api.md) · [All operations](https://skmtc.dev/gtmswag/apis/gtm-swag-api/llms.txt) · [OpenAPI document](https://skmtc.dev/gtmswag/apis/gtm-swag-api/revisions/9ff86fa99afe?raw)
