Guides

Build a daily CRM sync

Push every contact from your own system to Postaged once a day, mapped and typed.

This guide builds a scheduled job that pushes every contact from your own system to Postaged once a day, with every useful column mapped and typed. It is the same guide the Integrations MCP server gives an agent through its get_docs tool and its build_daily_sync prompt, written for a person, so you and your agent follow the same rules.

Postaged mails people from this data, so an address that is wrong or stale costs real money and real paper.

What you need

  • A custom integration in Postaged. Its page shows its Integration ID and its MCP server address.
  • An integration key (it starts with pk_int_), created with New key on that page and stored in an environment variable named POSTAGED_API_KEY. Never hard-code it, put it in a URL, log it, or commit it.
  • The API base URL: https://www.postaged.com/api/integrations/push/v1.

The daily job calls this HTTP API directly, so it keeps working with no agent involved. The Integrations MCP server is for building and testing the job: see Connect Claude.

Terminal
export POSTAGED_API_KEY="<paste your integration key here>"

Step 1: Look at the data

Find where your contacts live. For each column, work out what it means and which Postaged type fits: text, number, date, yes_no, or choice. Identify which columns are the mailing address, city, state, postal code, first and last name, email, and company, if they exist.

Step 2: Agree the mapping before you push

Make a table of every column you intend to send: its key, its label, its type, and which Postaged contact field it maps to (or none). Note which columns you are not sending, and why.

Have the owner of the data confirm the table before you push anything. The mapping decides which column becomes a mailing address, and a wrong one sends real cards to the wrong place.

Step 3: Declare the schema

Declare the schema with POST /schema and { "fields": [{ "key", "label", "type", "semantic" }] }.

  • semantic maps a field to a Postaged contact column: first_name, last_name, address_1, address_2, city, state, zip, email, phone, and organization. Leave it out for a field that is just data.
  • Up to 1,000 fields.
  • Formula and AI columns can't be set through the API; values sent for them are ignored and counted in the request log. Declare only fields your system owns.
  • A declaration is a proposal. If the owner has already set a field's type in Postaged, yours does not override it, and the call fails with field_locked naming the fields. That is not an error to work around: ask the owner.
curl https://www.postaged.com/api/integrations/push/v1/schema \
  -H "Authorization: Bearer $POSTAGED_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "fields": [
      {
        "key": "email",
        "label": "Email",
        "type": "text",
        "semantic": "email"
      },
      {
        "key": "first_name",
        "label": "First name",
        "type": "text",
        "semantic": "first_name"
      },
      {
        "key": "deal_value",
        "label": "Deal value",
        "type": "number"
      }
    ]
  }'
Response200 OK
{
  "ok": true,
  "added": 1,
  "noLongerSent": []
}

Step 4: Push a full sync

Send the complete set of contacts every run. Read them from your source a page at a time, by cursor or offset, and keep going until a page comes back empty. Never put a limit, a sample, or a date filter on the read: a full sync marks every contact it did not receive as removed, so a capped read quietly removes everyone past the cap.

Begin the sync

Begin a sync with POST /sync, { "mode": "full" }, and an Idempotency-Key header. It returns a syncId.

Use full whenever you send the complete set, which is what the daily job does: it marks every contact it did not see as removed. delta removes only what you delete explicitly; use it only for a small follow-up change, never as the daily job. Only one sync can be open at a time.

curl https://www.postaged.com/api/integrations/push/v1/sync \
  -H "Authorization: Bearer $POSTAGED_API_KEY" \
  -H "Idempotency-Key: 2026-10-03-begin-1" \
  -H "Content-Type: application/json" \
  -d '{
    "mode": "full"
  }'
Response200 OK
{
  "syncId": "0b6f3c3e-8d2a-4f7e-9a51-2c4d8e6f1a20",
  "mode": "full",
  "expiresAt": "2026-10-04T14:05:00.000Z"
}

Push the records

Push records with POST /records and { "records": [...] }, up to 1,000 records or 4.25 MB per batch, until every contact has been sent.

  • Every record needs external_id (your system's stable id for the contact) and fields.
  • Send each contact exactly once per sync, under the same external_id every day.
  • Use "op": "delete" to remove one.

Check your records first with POST /records?validate=1, which applies the same rules and writes nothing. See Testing.

curl https://www.postaged.com/api/integrations/push/v1/records \
  -H "Authorization: Bearer $POSTAGED_API_KEY" \
  -H "Idempotency-Key: 2026-10-03-records-1" \
  -H "Content-Type: application/json" \
  -d '{
    "records": [
      {
        "external_id": "crm-1042",
        "fields": {
          "email": "dana@example.com",
          "first_name": "Dana",
          "deal_value": "1500"
        }
      },
      {
        "external_id": "crm-0977",
        "op": "delete"
      }
    ]
  }'
Response200 OK
{
  "accepted": 1,
  "deleted": 1,
  "rejected": 1,
  "rejections": [
    {
      "index": 2,
      "externalId": null,
      "code": "missing_external_id",
      "message": "Every record needs an external_id: it is how this record is recognized on the next sync."
    }
  ],
  "warnings": [
    {
      "index": 0,
      "externalId": "crm-1042",
      "code": "invalid_type",
      "field": "deal_value",
      "message": "\"deal_value\" is declared as number and this value is not one. The record was accepted without it.",
      "count": 1,
      "externalIds": ["crm-1042"]
    }
  ]
}

Complete the sync

Always finish with Complete a sync (PATCH /sync, with its own Idempotency-Key), even when some records were rejected. A sync that is never completed expires after 24 hours: the contacts it sent are kept, but nothing is marked as removed and the owner sees a warning that it did not finish.

curl -X PATCH https://www.postaged.com/api/integrations/push/v1/sync \
  -H "Authorization: Bearer $POSTAGED_API_KEY" \
  -H "Idempotency-Key: 2026-10-03-complete-1" \
  -H "Content-Type: application/json" \
  -d '{
    "syncId": "0b6f3c3e-8d2a-4f7e-9a51-2c4d8e6f1a20"
  }'
Response200 OK
{
  "ok": true,
  "syncId": "0b6f3c3e-8d2a-4f7e-9a51-2c4d8e6f1a20",
  "mode": "full",
  "recordsUpserted": 1998,
  "recordsDeleted": 0,
  "recordsRejected": 2,
  "markedRemoved": 14
}

Idempotency keys

Every POST /sync, POST /records, and PATCH /sync needs its own Idempotency-Key, unique to that one request, for example <date>-<step>-<n>: 2026-09-22-begin-1, 2026-09-22-records-1, 2026-09-22-records-2, 2026-09-22-complete-1. Never use one key for a whole day or a whole run.

Send the same request again with the same key and you get the original response back, so a timeout is always safe to retry. A key reused with a different body, or on a different endpoint, is refused with idempotency_key_reused. See Idempotency and retries.

Rejections and warnings

A batch responds with what was accepted and one rejection per bad record, so one malformed record never stops the rest. Log the rejections; don't send them again unchanged.

codeWhat to do
missing_external_idSend an external_id on every record: your system's stable id for the contact.
invalid_external_idShorten the external_id to 128 characters or fewer.
unknown_fieldDeclare the field with POST /api/integrations/push/v1/schema before you push it, or stop sending it.
invalid_typeSend a value that fits the field's declared type. This rejects the record only for a field mapped to a contact column (it has a semantic); any other field drops just the value, as a warning.
invalid_emailSend a complete email address, or leave the field empty.
duplicate_in_batchSend each contact once per batch. Merge the duplicates under one external_id.
record_too_largeSend less data in this record. A record may be at most 64 KB.

A value that does not fit its declared type on a field with no semantic is dropped rather than failing the record: the record is accepted without that value, and the response lists it under warnings, one entry per field with a count and up to 5 example externalIds. A field mapped to a contact column (a name, an address part, email, phone, company) stays strict: a bad value there rejects the record.

codeWhat to do
invalid_typeThe record was accepted without this value. Send values that fit the field's declared type, or declare the field as text.

Fetch a sync's rejections again at any time with GET /sync/rejections?syncId=<id> (add &type=warning for the dropped values). Pages hold up to 1,000; pass nextAfter back as after until it is null. See Pagination.

curl "https://www.postaged.com/api/integrations/push/v1/sync/rejections?syncId=0b6f3c3e-8d2a-4f7e-9a51-2c4d8e6f1a20" \
  -H "Authorization: Bearer $POSTAGED_API_KEY"

Schedule it

Pick one that fits your project:

  • Vercel Cron (vercel.json), if it deploys to Vercel.
  • pg_cron, if the database is Postgres and the job can run as SQL.
  • GitHub Actions on a schedule, if the repository is on GitHub and has no server.
  • OS cron or a systemd timer, on a server you control.
  • node-cron in a long-running process, only if one already exists.

Make the job idempotent: a run that fails partway must be safe to run again. A new run gets new keys; a retried request inside a run reuses its own key.

Security

  • HTTPS only.
  • The key goes in the Authorization: Bearer header, never a query string.
  • Never print the key, and redact it from any error you log.

After the first run

  • Compare how many contacts the source holds, how many the first sync sent, how many were accepted, how many were rejected and why, and how many values were dropped. If the source count and the accepted count differ, find out why.
  • Finish setup in Postaged, on the integration's page, to choose which fields to keep before the contacts can be mailed.
  • To rotate the key: create a new key in Postaged, update the environment variable, then revoke the old one.

Let Claude build it

Build it with Claude on your integration's page gives you this guide as a prompt for your own Claude, with your integration's ID and addresses filled in. Connect the Integrations MCP server so Claude can inspect the integration and validate records while it builds: see Connect Claude.