# Gift contacts from a HubSpot workflow

GTM Swag helps go-to-market teams get meetings by sending personalized, thoughtful gifts. A HubSpot workflow adds each enrolled contact to a GTM Swag playbook when a good moment happens, such as a new role, a closed deal or a renewal. An owner or admin on your team approves each gift in GTM Swag, or turns on auto-approve so gifts within limits they set are approved right away. You can write the result, and later the gift link, back to the contact.

## Before you start

1. **A GTM Swag account.** Create one at https://gtmswag.com/login if you don't have one.
2. **A playbook that is on.** In GTM Swag, open **Integrations** and choose HubSpot. Pick a playbook (an always-on campaign) or create one there, then a teammate selects **Turn on playbook**. Its campaign ID is in the playbook URL: `https://gtmswag.com/campaigns/CAMPAIGN_ID`. If the note uses `{{company}}`, every contact needs a company. Change the playbook's settings in GTM Swag, not through the API: an edit made with a key (`PATCH /api/v1/campaigns/CAMPAIGN_ID`) returns the playbook to draft, and every add then fails with `409 PLAYBOOK_OFF` until a teammate turns it on again.
3. **Funds and an approver.** Playbook gifts are paid from the workspace balance. An owner or admin adds funds in **Settings → Billing & invoices**, and either approves each gift or turns on auto-approve on the playbook's page.
4. **A workspace key.** Create one in **Integrations → For developers → Create key**. It starts with `gtm_`. Store it only in HubSpot's secrets.
5. **Delivery status.** Until GTM Swag enables delivery for your workspace, everything works end to end, but no gift ships and no real payment is taken: test with your own contacts, not real prospects. To enable delivery, email eric@betterswag.com. Once delivery is enabled, each gift needs a US shipping address you send (`addressMode: "known"`); the custom code below does that when the contact has one. Contacts without one wait in the queue with "Needs a shipping address". The balance is kept separately once delivery is enabled, so add funds again then.

## Build the workflow

1. In HubSpot, create a contact-based workflow.
2. **Enrollment trigger:** the gifting moment, for example **Job title** contains any of `VP of Sales`, `Vice President of Sales`, `Head of Sales`, or **Lifecycle stage** is **Customer**.
3. **Re-enrollment:** leave it off. A playbook gifts each contact once, so a second enrollment can't send a second gift anyway: the add matches the contact by `sourceSystem` and `externalId` and returns its current approval. To retry contacts, use a separate retry workflow (see "Retry a contact").
4. **Add the person** with a **Custom code** action (recommended, below) or a **Send a webhook** action.
5. Optional: write the results back to the contact, and later fetch the gift link (see "Write the gift link back").

## Add the person with a Custom code action

Custom code can retry a busy playbook, which the **Send a webhook** action can't. Custom code actions need a HubSpot subscription that includes them, and HubSpot stops one after 20 seconds, so the code below gives itself 15.

1. Add a **Custom code** action with language Node.js.
2. Add a secret named `GTM_SWAG_KEY` whose value is the key alone (`gtm_…`, without `Bearer `).
3. Add these properties to include in code: `firstname`, `lastname`, `company`, `email`, `jobtitle`, `hs_object_id`, `address`, `city`, `state`, `zip`.
4. Paste the code, replacing `CAMPAIGN_ID`.
5. Add data outputs (all strings): `recipientId`, `approvalState`, `approvalReason`, `approvalUrl`, `sendId`.

```js
const axios = require("axios");

const ADD_URL = "https://gtmswag.com/api/v1/playbooks/CAMPAIGN_ID/people";
// HubSpot stops custom code after 20 seconds; finish well inside that.
const BUDGET_MS = 15000;
// GTM Swag takes two-letter state codes; HubSpot often stores full names.
const STATES = {
  alabama: "AL", alaska: "AK", arizona: "AZ", arkansas: "AR",
  california: "CA", colorado: "CO", connecticut: "CT", delaware: "DE",
  "district of columbia": "DC", florida: "FL", georgia: "GA", hawaii: "HI",
  idaho: "ID", illinois: "IL", indiana: "IN", iowa: "IA", kansas: "KS",
  kentucky: "KY", louisiana: "LA", maine: "ME", maryland: "MD",
  massachusetts: "MA", michigan: "MI", minnesota: "MN", mississippi: "MS",
  missouri: "MO", montana: "MT", nebraska: "NE", nevada: "NV",
  "new hampshire": "NH", "new jersey": "NJ", "new mexico": "NM",
  "new york": "NY", "north carolina": "NC", "north dakota": "ND",
  ohio: "OH", oklahoma: "OK", oregon: "OR", pennsylvania: "PA",
  "rhode island": "RI", "south carolina": "SC", "south dakota": "SD",
  tennessee: "TN", texas: "TX", utah: "UT", vermont: "VT", virginia: "VA",
  washington: "WA", "west virginia": "WV", wisconsin: "WI", wyoming: "WY",
};
const CODES = new Set(Object.values(STATES));
const stateCode = (value) => {
  const text = String(value || "").trim().replace(/\s+/g, " ");
  const code = STATES[text.toLowerCase()] || text.toUpperCase();
  return CODES.has(code) ? code : "";
};
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

exports.main = async (event, callback) => {
  const started = Date.now();
  const contact = event.inputFields;
  const recipient = {
    name: [contact.firstname, contact.lastname].filter(Boolean).join(" "),
    sourceSystem: "hubspot",
    externalId: String(contact.hs_object_id),
    addressMode: "confirm",
  };
  if (contact.company) recipient.company = contact.company;
  if (contact.email) recipient.email = contact.email;
  if (contact.jobtitle) recipient.title = contact.jobtitle;
  // A complete US address ships straight to it.
  const state = stateCode(contact.state);
  const zip = String(contact.zip || "").trim();
  if (contact.address && contact.city && state && /^\d{5}(-\d{4})?$/.test(zip))
    Object.assign(recipient, {
      addressMode: "known",
      address: contact.address,
      city: contact.city,
      state,
      zip,
      country: "US",
    });

  for (;;) {
    const left = BUDGET_MS - (Date.now() - started);
    if (left < 2000)
      throw new Error(
        "TIMEOUT: GTM Swag did not answer in time. Retry this contact; it is never added twice.",
      );
    try {
      const response = await axios.post(
        ADD_URL,
        { recipient },
        {
          headers: { Authorization: `Bearer ${process.env.GTM_SWAG_KEY}` },
          timeout: Math.min(5000, left - 500),
        },
      );
      const added = response.data.data;
      return callback({
        outputFields: {
          recipientId: added.recipientId,
          approvalState: added.approval.state,
          approvalReason: added.approval.reason || "",
          approvalUrl: added.approvalUrl,
          sendId: added.approval.sendId || "",
        },
      });
    } catch (error) {
      const status = error.response?.status;
      const body = error.response?.data || {};
      // Sent but no answer (a timeout or a dropped connection): the add may
      // have landed. Sending the same contact again is safe.
      if (!error.response && error.request) continue;
      // Other contacts were being added at the same moment. Nothing was
      // saved; wait the short retryAfterMs and send the same request.
      if (body.code === "PLAYBOOK_BUSY") {
        await sleep(body.retryAfterMs || 1000);
        continue;
      }
      // Rate limits and server errors: rethrow so HubSpot retries the
      // action later. Sending the same contact again is safe.
      if (status === 429 || status >= 500) throw error;
      throw new Error(`${body.code || "REQUEST_FAILED"}: ${body.error || error.message}`);
    }
  }
};
```

`sourceSystem: "hubspot"` with the contact's `hs_object_id` as `externalId` makes every retry safe: the same contact returns `alreadyQueued: true` instead of a second gift. `country` defaults to `US`, the only country GTM Swag ships to; a state that isn't a US state or DC, or an incomplete address, leaves the contact on `addressMode: "confirm"`.

**When the action fails.**

- A `429` or `5xx` answer is rethrown, so HubSpot retries the action on its own for up to three days, starting a minute later and waiting longer between tries (up to eight hours).
- A `TIMEOUT` (no answer within 15 seconds) or an error such as `409 PLAYBOOK_OFF` or `400 VALIDATION_ERROR` fails the action, with the code shown in the workflow's history. HubSpot doesn't retry these. Fix the cause, then retry the contact (below).

## Retry a contact

Build a second contact-based workflow, for example "GTM Swag retry", with the same **Custom code** action and outputs (copy the action). Enroll contacts in it by hand from the workflow's **Enroll** button, or with a trigger such as **GTM Swag approval reason** is `low_balance`; turn on re-enrollment in this workflow if you'll retry the same contact more than once. Running the add again never creates a second gift: it returns the contact's current approval.

- **The action failed** (`TIMEOUT`, `PLAYBOOK_OFF`, `VALIDATION_ERROR`): fix the cause, then enroll the contact in the retry workflow.
- **Waiting with `low_balance`:** adding funds doesn't re-check people already waiting. With auto-approve on, enroll them in the retry workflow after an owner or admin adds funds: the add runs auto-approve again and can approve and charge them, within its limits. Without auto-approve, an owner or admin approves them from the queue at `approvalUrl`; adding again changes nothing.
- **Waiting for any other reason** (`auto_approve_off`, `over_gift_limit`, `monthly_cap` and so on): an owner or admin approves at `approvalUrl`. Turning auto-approve on, or raising a limit, doesn't approve people already waiting either; retry them the same way.

## Or add the person with a Send a webhook action

- Method: `POST`
- Webhook URL: `https://gtmswag.com/api/v1/playbooks/CAMPAIGN_ID/people`
- Authentication: API key in the request header, with key name `Authorization` and value `Bearer YOUR_GTM_SWAG_KEY` (here the value includes `Bearer ` and a space).
- Request body: customize it so HubSpot sends exactly this JSON, with contact properties in place of the placeholders:

```json
{
  "recipient": {
    "name": "{{contact.firstname}} {{contact.lastname}}",
    "company": "{{contact.company}}",
    "email": "{{contact.email}}",
    "sourceSystem": "hubspot",
    "externalId": "{{contact.hs_object_id}}",
    "addressMode": "confirm"
  }
}
```

GTM Swag accepts only the `recipient` object and refuses unknown fields with `400 VALIDATION_ERROR`, so a default webhook body that sends every property fails. The webhook body can't convert a state name to its code, so leave the address out here. HubSpot doesn't retry a `4xx` webhook response other than `429`, so a contact that hits `409 PLAYBOOK_BUSY` is not added; use the custom code action when several contacts can enroll at once.

## What the response means

A successful add returns `200`:

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

- `waiting`: the gift waits for an owner or admin at `approvalUrl` (Home in GTM Swag, filtered to the playbook). `reason` says why: `auto_approve_off`, `over_gift_limit`, `monthly_cap`, `low_balance` (add funds in **Settings → Billing & invoices**), `repeat_recipient`, `photo_review`, `age_restricted`, `shipping_quote` (the gift's delivery isn't priced yet, so it always waits for a person), `email_unverified` or `blocked` (for example, once delivery is enabled, a contact without a shipping address).
- `auto_approved`: auto-approve approved the gift and paid it from the balance; `sendId` names it.
- `approved`: the contact's gift was already approved.
- `scheduled`: auto-approve finishes it in the background, usually within minutes.
- `skipped`: a teammate skipped this contact.

Without auto-approve, adding a contact never charges or ships anything; approval charges the balance. Adding the same contact again never approves or charges twice.

## Write the gift link back

With `addressMode: "confirm"`, the recipient confirms their own US address through a private gift link. GTM Swag creates the link once the gift is approved and never emails it: you send it in your own outreach, for example by merging a contact property into a sequence email. Contacts added with `addressMode: "known"` ship to that address and have no link. A link belongs to one recipient and works for whoever holds it, so keep the property off shared views and reports. It expires 14 days after the gift is created, opened or not, so time approvals close to your outreach; an unused gift whose link expired can be regifted in GTM Swag for a fresh 14-day link.

1. Create contact properties (single-line text): **GTM Swag recipient ID**, **GTM Swag approval state**, **GTM Swag approval reason** and **GTM Swag gift link**.
2. After the custom code action, add an **Edit record** action that sets the first three from the `recipientId`, `approvalState` and `approvalReason` outputs.
3. Add a **Delay** (an hour is enough for auto-approved gifts; waiting gifts need a person first), then a second **Custom code** action with the same secret, the property `gtm_swag_recipient_id` (the internal name of **GTM Swag recipient ID**) and outputs `giftLink`, `giftExpiresAt`, `linkStatus`, `reason`:

   ```js
   const axios = require("axios");

   exports.main = async (event, callback) => {
     // A 429 or 5xx answer is thrown as is, so HubSpot retries it.
     const response = await axios.get(
       "https://gtmswag.com/api/v1/campaigns/CAMPAIGN_ID/distribution",
       {
         params: { recipientIds: event.inputFields.gtm_swag_recipient_id },
         headers: { Authorization: `Bearer ${process.env.GTM_SWAG_KEY}` },
         timeout: 10000,
       },
     );
     const record = response.data.data.records[0] || {};
     // No gift_id yet means the contact still waits for approval.
     const linkStatus =
       record.availability === "ready"
         ? "ready"
         : record.availability === "unavailable"
           ? "unavailable"
           : record.gift_id
             ? "preparing"
             : "waiting";
     callback({
       outputFields: {
         giftLink: linkStatus === "ready" ? record.gift_link : "",
         giftExpiresAt: record.gift_expires_at || "",
         linkStatus,
         reason: record.availability_reason || "",
       },
     });
   };
   ```

4. Add an **If/then branch** on `linkStatus`:
   - `ready`: an **Edit record** action sets **GTM Swag gift link** from `giftLink`.
   - `preparing`: the gift exists and its link is being created. Delay 10 minutes and run the fetch again.
   - `waiting`: no gift yet; the contact waits for an owner or admin (or for auto-approve to finish). Delay a day and run the fetch again.
   - `unavailable`: stop. `reason` says why, such as `known_address`, `claimed`, `expired` or, once delivery is enabled, `live_addressless_unavailable`.

   HubSpot workflows run top to bottom, so repeat the delay-and-fetch pair a few times (for example after an hour, a day and three days). Reading a link never buys, sends or renews a gift.

For many contacts, or a waiting time you can't predict, fetch links outside the workflow instead. A signed webhook for `send.created` (below) fires once each gift exists and carries the HubSpot record ID as `attribution.externalId`; your receiver then reads that gift's link with `GET https://gtmswag.com/api/v1/campaigns/CAMPAIGN_ID/distribution?giftIds=SEND_ID` and updates the contact through HubSpot's API. A scheduled job can poll `GET https://gtmswag.com/api/v1/events?type=send.created` instead, keeping the returned `nextCursor` between runs. To read every ready link at once, `POST https://gtmswag.com/api/v1/campaigns/CAMPAIGN_ID/distribution/exports` with `{"scope": "all"}` returns a CSV at its `downloadUrl` (fetch it with the same key).

## Track

An owner or admin can add a signed webhook in **Integrations → For developers → Signed webhooks** for `send.created`, `send.link_opened` and `send.status_changed`. Events carry `attribution.externalId`, the HubSpot record ID you sent, so your receiver can update the contact; verify each delivery's signature (https://gtmswag.com/api-docs.md, "Signed outgoing webhooks"). Add `playbook.person_waiting` to hear when a contact waits for approval: it carries `externalId`, `reason` and `approvalUrl`, with no names or emails. A link open is not a reply or a meeting, and GTM Swag doesn't ask you to record either.

## Errors

- `409 PLAYBOOK_OFF`: the playbook is paused or a draft, for example after an edit through the API. A teammate turns it on at the returned `url`.
- `409 PLAYBOOK_BUSY`: other contacts were being added at the same moment; nothing was saved. The custom code retries it.
- `409 NOT_ALWAYS_ON`: the campaign ID is a batch campaign; use a playbook.
- `400 RECIPIENT_MESSAGE_REQUIRED`: the playbook note uses a placeholder, such as `{{company}}`, that the contact lacks.
- `400 VALIDATION_ERROR`: a field is invalid, for example a state longer than two letters sent from a webhook body.
- `401 AUTH_REQUIRED`: check the secret. Custom code adds `Bearer ` itself; the webhook value must include it.
- `429 RATE_LIMIT`: the workspace allows 240 requests per minute across all tools. HubSpot retries the action later.

More: https://gtmswag.com/guides/connect.md and https://gtmswag.com/api-docs.md
