Send a handwritten card from a HubSpot workflow
When a contact becomes a customer in HubSpot, mail them a card written with a real pen. One custom code action in a workflow calls the notecardHQ API. There is no notecardHQ app to install.
What you need
- HubSpot Data Hub Professional or Enterprise. Custom code actions are offered with those subscriptions (source).
- A contact-based workflow. The example enrolls contacts when their Lifecycle stage, a default contact property (source), becomes Customer.
- A notecardHQ API key, test key first.
Why not the "Send a webhook" action
HubSpot's Send a webhook action can POST chosen properties or static values and can place one API key in a request header (source). HubSpot's article does not describe setting any other header. notecardHQ needs two: Authorization and an Idempotency-Key that changes with every record, so a retry never mails twice. The webhook action has no documented way to send the second one, so use a custom code action. Not on Data Hub Professional? Send the card through Zapier, Make or n8n instead.
Set up the action
- In the workflow, add a Custom code action with the Node.js runtime (source).
- Secrets: add a secret named
NOTECARDHQ_API_KEYholding your key. HubSpot exposes secrets to the code as environment variables (source). - Properties to include in code: add first name, last name, company name, street address, city, state/region, postal code and country/region. The code reads them from
event.inputFields(source). The code below expects the namesfirstname,lastname,company,address,city,state,zipandcountry; match them to the names in your action. - Paste the code and test it on a contact with your own address.
The code
const axios = require("axios");
// HubSpot stores the country as text. notecardHQ wants a 2-letter code.
const COUNTRY = { "united states": "US", usa: "US", canada: "CA", "united kingdom": "GB" };
const has = (v) => typeof v === "string" && v.trim().length > 0;
exports.main = async (event, callback) => {
const f = event.inputFields;
const id = event.object.objectId;
const raw = has(f.country) ? f.country.trim() : "US";
const country = raw.length === 2 ? raw.toUpperCase() : COUNTRY[raw.toLowerCase()];
if (has(f.firstname) && has(f.address) && has(f.city) && country) {
try {
const res = await axios.post(
"https://you.notecardhq.com/api/v1/notes",
{
externalId: `hubspot:${id}`,
recipient: {
firstName: f.firstname,
lastName: f.lastname || "",
businessName: f.company || "",
address1: f.address,
city: f.city,
state: f.state || "",
postalCode: f.zip || "",
country,
},
content: `Hi ${f.firstname},\n\nThank you for choosing us. It is a pleasure to work with you.\n\nThe team`,
},
{
headers: {
Authorization: `Bearer ${process.env.NOTECARDHQ_API_KEY}`,
// One card per contact, however often the workflow runs.
"Idempotency-Key": `hubspot:customer:${id}`,
},
timeout: 15000,
},
);
return callback({ outputFields: { notecard_id: res.data.id, notecard_status: res.data.status } });
} catch (err) {
const status = err.response?.status;
// Throwing on 429 and 5xx lets HubSpot retry; the same key never sends twice.
if (status === undefined || status === 429 || status >= 500) throw err;
console.error("notecardHQ", status, JSON.stringify(err.response.data));
return callback({ outputFields: { notecard_id: "", notecard_status: `error ${status}` } });
}
}
callback({ outputFields: { notecard_id: "", notecard_status: "skipped: no address" } });
};
The request it sends
POST https://you.notecardhq.com/api/v1/notes
Authorization: Bearer mos_test_...
Content-Type: application/json
Idempotency-Key: hubspot:customer:51
{
"externalId": "hubspot:51",
"recipient": {
"firstName": "Sam", "lastName": "Lee", "businessName": "",
"address1": "123 Main St", "city": "Austin",
"state": "TX", "postalCode": "78701", "country": "US"
},
"content": "Hi Sam,\n\nThank you for choosing us. It is a pleasure to work with you.\n\nThe team"
}
A stable Idempotency-Key
The key is the contact's record id plus the event name: hubspot:customer:51. If HubSpot retries the action, or the contact enrolls again, the same key returns the original note instead of mailing a second card. For a card on every deal instead of once per contact, run the workflow on deals and key it on the deal id, for example hubspot:deal-won:9876.
Build with a test key
Create a test key (mos_test_) under Integrations in the portal and use it until the whole flow works. A test key runs the same validation as a live key, but the note is never written, mailed or billed. Then switch to your live key and send one card to your own address before you turn the automation on for customers.
Limits and gotchas
- A custom code action must finish within 20 seconds and use at most 128 MB of memory (source). The code above gives notecardHQ 15 seconds.
- When the code throws on a 429 or 5xx from axios, HubSpot retries the action for up to three days, starting one minute after the failure (source). The key stays the same on every try.
- All secrets used by one action must total 1,000 characters or fewer (source). A notecardHQ key is well under that.
- Country/Region is free text in many portals. Extend the
COUNTRYmap for the countries you mail to; contacts it cannot map are skipped, not guessed. - The message is written exactly as sent, up to 500 characters including the greeting and sign-off.
Errors
- 401. Missing or wrong key. Fix the key; do not retry.
- 409. The Idempotency-Key was already used with a different body. Stop and check; never change the key to get past it.
- 422. A missing field, a character the pen cannot write in the note (for example an emoji; accented letters are written without the accent), or an undeliverable US address. Nothing was created; fix the data.
- 429 or 5xx. Temporary. Retry with the same key and the same body.
Choosing triggers and copy, or deciding between this and Zapier? See send handwritten notes from HubSpot, and how to schedule cards for birthdays and anniversaries with sendOn.
Vendor details checked on October 26, 2026 against HubSpot workflow webhooks, HubSpot custom code actions, HubSpot default contact properties. Platforms change their plans and features; check the source before you build.
Field rules, statuses and errors are in the API reference. Prefer another language? See the quickstart.
Send your first note.
Tell us what you want to send and we will set up your workspace.