notecardHQAPIPortal

Notes API

Post one request per recipient and a pen robot writes the card by hand, then we stamp and mail it.

Overview

Base URLhttps://app.notecardhq.com/api/v1
Machine-readable schemaGET https://app.notecardhq.com/api/v1/notes
Portalapp.notecardhq.com
  1. You POST a note. We check the address and the text and return an id with status created.
  2. Within seconds the note is turned into pen strokes and checked by OCR before anything is written.
  3. A robot in our New York office writes the card (card_printed).
  4. We stamp and mail it (card_dispatched).

Every note, its status and its context are visible under Activity in the portal.

Quickstart

With a live key this request mails a real card. Use a test key (mos_test_...) while you build: same request, same checks, nothing mailed.

curl -X POST https://app.notecardhq.com/api/v1/notes \
  -H "Authorization: Bearer $NOTECARDHQ_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: milestone:user_123" \
  -d '{
    "externalId": "user_123",
    "recipient": {
      "firstName": "Brandy",
      "lastName": "Lee",
      "businessName": "Aura Beads",
      "address1": "123 Main St",
      "address2": "Apt 4",
      "city": "Austin",
      "state": "TX",
      "postalCode": "78701",
      "country": "US"
    },
    "content": "Hi Brandy,\n\nCongrats on your first $1! Keep building, and tag @us when you hit the next one.\n\nThe team team",
    "context": { "milestone": "milestone", "user_id": "user_123" }
  }'

The response is the note, with id and status: "created". Save the id.

Authentication and idempotency

  • Key. Send Authorization: Bearer <key>. Create or replace it under Integrations in the portal; a new key retires the old one immediately. Keep it in a server-side secret store, never in frontend code, a repository or logs.
  • Idempotency-Key (required, 1 to 200 characters). Use one stable key per recipient and milestone, such as milestone:user_123. The same key and body returns the original note and sends no second card. The same key with a different body returns 409: stop and investigate, never mint a new key to get past it.
  • Content-Type must be application/json.

Request fields

FieldRequiredNotes
recipient.firstNameYesLatin script
recipient.lastNameNo
recipient.businessNameNo
recipient.address1YesStreet address
recipient.address2NoApartment or suite
recipient.cityYes
recipient.stateUS onlyState, province or region
recipient.postalCodeUS onlySend it wherever one exists
recipient.countryNoISO 3166-1 alpha-2 (US, GB, BR). Defaults to US; always send it.
contentYes1 to 500 characters, greeting and sign-off included. Written exactly as sent.
externalIdNoYour ID for the recipient; shown in the portal
contextNoJSON object, up to 100 fields and 8 KB. Stored and shown in the portal, never written on the card.

Addresses. US addresses are verified and may be standardized (6 W 18th St Suite 2C comes back as 6 W 18TH ST STE 2C). An undeliverable US address returns 422 and creates nothing. International addresses are stored as sent, so send them complete.

What can be written

Letters, digits, spaces, line breaks and . , ! ? ' " - : ; ( ) / @ $ & % # + =. So @Acme and $1 are fine.

Anything else is refused with 422, and the error names the character: * < > [ ] { } | \ ^ _ ~ `, currency signs other than $, emoji, and em or en dashes. Accents are removed automatically (José becomes Jose).

Responses and statuses

A 200 means the card is queued. Follow it with GET https://app.notecardhq.com/api/v1/notes/{id} and the same key.

StatusMeaning
createdAccepted and queued for the robots
card_printedWritten by the robot
card_dispatchedStamped and mailed. This is when it bills.
cancelledStopped before mailing. Not billed.

To stop a note before it is written, cancel it under Activity in the portal.

Errors and retries

CodeWhenWhat to do
200Note created, or the same request repeatedSave the id
401Missing or wrong keyFix the key. Do not retry.
409Idempotency key reused with a different bodyStop and investigate
413 / 415Body too large, or not JSONFix the request
422Bad field, unwritable character or undeliverable US addressFix the data. Nothing was created.
429 / 5xxTemporary, or a network errorRetry with backoff, same key and same body

Errors come back as {"error": "<reason>"} in plain English. Never log the key or recipient addresses.

Test keys

Every workspace can hold one live key (mos_...) and one test key (mos_test_...). Create both under Integrations in the portal.

  • Same checks. A test key runs exactly the validation a live key does, address verification included, and returns the same errors.
  • Nothing real happens. The note is stored with "test": true and is never written, mailed or billed.
  • Simulated lifecycle. A test note in created moves to card_printed a minute later and card_dispatched a minute after that, firing the same webhooks a live note would. Cancelling works as live.
  • Separate data. GET https://app.notecardhq.com/api/v1/notes/{id} with a test key returns only test notes, and with a live key only live notes. Idempotency keys never collide between the two.

Test notes show under Activity in the portal with a TEST badge.

Webhooks

Add an HTTPS endpoint under Integrations in the portal and we POST every status change of a note to it, signed with your webhook secret (whsec_..., shown in the portal).

EventWhen
note.createdAccepted and queued for the robots
note.needs_reviewHeld for a person to review the copy
note.card_printedWritten by the robot
note.card_dispatchedStamped and mailed
note.cancelledStopped before mailing
POST <your URL>
Content-Type: application/json
notecardHQ-Event-Id: evt_...
notecardHQ-Signature: t=1792900000,v1=5f2b...

{
  "id": "evt_...",
  "type": "note.card_printed",
  "created": 1792900000,
  "test": false,
  "data": { ...the note, exactly as GET /api/v1/notes/{id} returns it }
}
  • Signature. v1 is the hex HMAC-SHA256 of <t>.<raw body> with your secret, the same scheme as Stripe's, so existing verifier code works. Reject events whose t is more than five minutes old.
  • Delivery. Answer with any 2xx within 10 seconds. Redirects are not followed: a 3xx counts as a failure, so post to the final URL. Anything else is retried after 1 minute, 5 minutes, 30 minutes, 2, 6, 12 and 24 hours, then dropped. Each retry is signed afresh.
  • Order and duplicates. Delivery order is not guaranteed; use created and the note's status. Treat id as unique and ignore repeats.
  • Try it. "Send test event" in the portal posts a sample note.created with "test": true, and the last 20 deliveries are listed with their response codes.

Verify in Node:

import { createHmac, timingSafeEqual } from "node:crypto";

// rawBody: the request body exactly as received (string or Buffer), not re-serialized JSON.
export function verifyNotecardHQ(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries((header || "").split(",").map((p) => p.split("=")));
  const t = Number(parts.t);
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
  const given = Buffer.from(parts.v1 || "", "hex");
  return given.length === expected.length && timingSafeEqual(given, expected);
}

// Express: keep the raw body for this route.
app.post("/webhooks/notecardhq", express.raw({ type: "application/json" }), (req, res) => {
  const raw = req.body.toString("utf8");
  if (!verifyNotecardHQ(raw, req.get("notecardHQ-Signature"), process.env.NOTECARDHQ_WEBHOOK_SECRET))
    return res.sendStatus(400);
  const event = JSON.parse(raw);
  // event.id is unique: skip it if you have seen it. event.test is true for test-key notes.
  // event.type: note.created | note.needs_review | note.card_printed | note.card_dispatched | note.cancelled
  res.sendStatus(200);
});

Polling for changes

If you cannot receive webhooks, poll GET https://app.notecardhq.com/api/v1/notes/recent with your key. It lists your notes newest status change first, each exactly as GET https://app.notecardhq.com/api/v1/notes/{id} returns it, plus updatedAt: when its status last changed (Unix ms).

ParameterNotes
limit1 to 100 notes per page. Defaults to 25.
updatedSinceOnly notes whose status changed at or after this time, ISO 8601 with a time zone, e.g. 2026-10-26T15:00:00Z.
cursorThe nextCursor of the previous page, for older notes. It is null on the last page.
{ "notes": [ { "id": "...", "status": "card_dispatched", ..., "updatedAt": 1761490800000 } ], "nextCursor": null }

Each note shows its current status, and moves back to the top when that changes, so remember id and status together to see each change once. A test key lists test notes only, a live key live notes only. A bad parameter returns 422.

Holdout groups

To measure what the cards do, keep a random control group that never gets one. Set a holdout of 1 to 50 percent under Settings in the portal (off by default), then compare that group's outcomes with everyone who got a card.

  • Stable assignment. A recipient is held out when the first four bytes of SHA-256(workspaceId + ":" + id), read as an unsigned integer, modulo 100, are below your percentage. id is your externalId, or the Idempotency-Key when there is none, so the same recipient is always in the same group, across retries and later events.
  • Same checks. A held-out request is validated exactly like any other, address verification included. It is then stored and answered with "status": "cancelled", "holdout": true: never written, mailed or billed, and not counted towards pricing tiers, review or daily limits. Record the group from the response.
  • Never hold out someone. Send "holdout": false (e.g. for VIPs). A request cannot put itself in the holdout, so "holdout": true is refused with 422.
  • Webhooks. A held-out note fires note.cancelled with "holdout": true on the event and on its data.
  • Report. GET https://app.notecardhq.com/api/v1/holdout?from=2026-01-01&to=2026-12-31 returns, per UTC month, how many notes went out as cards and how many were held out, with their externalIds, to join to your own outcomes. The portal shows the same under Activity, filter Holdout. A test key reports test notes.

Check a recipient's group yourself:

import { createHash } from "node:crypto";

// workspaceId is shown next to the holdout setting in the portal.
export function inHoldout(workspaceId, externalIdOrKey, holdoutPercent) {
  const digest = createHash("sha256").update(`${workspaceId}:${externalIdOrKey}`).digest();
  return digest.readUInt32BE(0) % 100 < holdoutPercent;
}

Testing and go-live

Build against a test key, then send one real note to yourself with the live key. Every accepted live POST mails a card.

  1. Store both keys in your secret manager and call the API from the event that should send a note.
  2. Run your integration with the test key, including 409, 422 and a retried 5xx. If you use webhooks, watch the test note move to card_printed and card_dispatched.
  3. Send one real note with the live key to an address you control. It is a real card and bills like one.
  4. Find it under Activity in the portal, and tell us when the card arrives.
  5. Turn on the live trigger.

Never use a live POST as a health check. GET https://app.notecardhq.com/api/v1/notes is free and safe, and so is any request made with a test key.

Billing

Cards bill when mailed (card_dispatched); cancelled notes never bill. Test notes are notes created with a test key, and only those are guaranteed never to bill. Your rate and how you are invoiced are under Billing in the portal.