Integrate your ERP with Hapee

The complete flows, field mapping and the go-live checklist. Written so it is enough without us at your side.

Before you write a line

Three things that define everything else:

  1. The subaccount comes from the credential. It is never sent as a parameter. A credential belongs to one subaccount and can only read and write there.
  2. There are two ways to find out what happens and they serve exactly the same thing: webhooks (we notify you) or GET /events (you poll). You choose based on whether your ERP can expose a public URL, not on what you want to receive.
  3. Every write accepts Idempotency-Key. A retry after a network drop won't duplicate anything. Use it from day one, not when the problem shows up.
curl https://beta.hapee.ai/api/v1/me \
  -H "Authorization: Bearer hp_test_YOUR_CREDENTIAL"

If this works, everything else works.

API or workflows?

There are three ways to connect and they don't compete: you use them together. The real difference is who writes the code.

What you want to doPathWho does it
Load thousands of customers and productsAPI + batchYour technical team
“Deal won → notify the ERP”WorkflowYour sales team, no code
“The ERP says it was paid”Webhook triggerOne HTTP call, no credential
Incremental sync without losing anythingAPI + webhooksYour technical team

With workflows, no code on the Hapee side

Inside Hapee, in the workflow builder, there are two pieces that talk to your system:

It is the fastest way to get started: the person who already uses Hapee builds it in an afternoon, without waiting on anyone.

⚠️ A 4xx from your side does NOT stop the workflow. The engine only retries when your system responds 5xx. If it responds 401 because the token is wrong, or 400 because it doesn't like the body, the workflow carries on as if it had worked and nobody finds out.

The response code is stored in the _http_status variable. Always add an “If / Else” node that checks it right after the outgoing webhook, and send the bad case to an internal notification. Without that, the day your token expires, sales “were reported” and never arrived.

In the workflow's execution log that step shows up marked as “no effect”, with the HTTP code next to it — but only whoever goes in to look sees that, while the “If / Else” node alerts you on its own.

When the API is better than the workflow

WorkflowAPI + webhooks
Retries if your system is down5 attempts, ~1 hour12 attempts, ~24 hours
Queryable delivery logIn the workflow logYes, with manual redelivery
A 4xx from your sideCarries on as if nothing happenedRetried and logged
SignatureOptional secret you configureHMAC with timestamp
Bulk loadNoUp to 100 per call
Who sets it upThe sales teamThe technical team

Our recommendation: start with workflows to test the connection end to end, and move to the API whatever can't be lost — collections, invoices and the initial load.

Source of truth: who owns what

You need to agree on this before syncing anything, because it decides what happens when both sides change the same data. The default proposal:

EntityOwnerReason
Tax data (RUT, legal name, line of business)ERPIt is what goes on the DTE
Products and list pricesERPIt is where the catalog is maintained
Invoices, credit notes, paymentsERPThe SII only knows the ERP
Deal stage, sales activityHapeeIt is where the sales team works
Contacts and their conversation historyHapeeIt is where they happen
Contact data (phone, email)Whoever touched it lastBoth are valid

What can't happen is not deciding. Without this agreement, each side overwrites the other and the symptom shows up weeks later as “the data keeps getting erased”.

Flow A — Initial load

One time only, when connecting. Use POST /{resource}/batch with up to 100 items per call. The batch is not aborted by a bad item: it takes the ones it can and tells you which failed.

curl -X POST https://beta.hapee.ai/api/v1/companies/batch \
  -H "Authorization: Bearer hp_test_YOUR_CREDENTIAL" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: initial-load-companies-batch-001" \
  -d '{
    "items": [
      {"external_id": "ERP-CLI-4471", "rut": "76.543.210-K",
       "nombre": "Comercial Andes SpA",
       "email": "contacto@andes.cl"},
      {"external_id": "ERP-CLI-4472", "rut": "77.111.222-3",
       "nombre": "Distribuidora Sur Ltda"}
    ]
  }'
{
  "job_id": 812,
  "total": 2, "succeeded": 2, "failed": 0,
  "results": [
    {"index": 0, "ok": true, "status": 201, "created": true, "data": {"id": 9001}},
    {"index": 1, "ok": true, "status": 201, "created": true, "data": {"id": 9002}}
  ]
}

Three things that will save you a headache

Recommended order: companies → product-categories and products → contacts.

Flow B — Incremental sync

ERP → Hapee

Use upsert, not create. Matching is the delicate part, and the precedence is deterministic:

external_id  >  rut  >  email  >  phone

One criterion at a time is tried, in order, and the first one that finds something wins. You can pin it with match_by:

curl -X POST https://beta.hapee.ai/api/v1/contacts/upsert \
  -H "Authorization: Bearer hp_test_YOUR_CREDENTIAL" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: sync-contact-CON-991-v3" \
  -d '{
    "match_by": "external_id",
    "external_id": "ERP-CON-991",
    "email": "jefe.compras@andes.cl",
    "first_name": "Rodrigo", "last_name": "Pinto",
    "phone": "+56912345678",
    "custom_fields": {"payment_terms": "30 días"}
  }'

match_by is exclusive. If you ask to match by external_id and there is no match, it creates — it does not fall back to email. This is on purpose: falling back would mean disobeying you exactly when you were explicit, and could overwrite someone your ERP considers a different person.

The RUT is validated. A wrong check digit returns 422 and nothing is saved. A badly saved RUT identifies nobody and also poisons all future deduplication: the next load with the correct RUT doesn't find it and creates a duplicate.

Hapee → ERP

Two options, same data. Option 1 — Webhooks (recommended if you can expose an https URL):

curl -X POST https://beta.hapee.ai/api/v1/webhooks/endpoints \
  -H "Authorization: Bearer hp_test_YOUR_CREDENTIAL" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://erp.yourcompany.com/hooks/hapee",
    "description": "ERP production",
    "events": ["deal.won", "quote.accepted", "contact.updated", "company.updated"]
  }'

The response includes the secret only once. See Webhooks for signature verification.

Option 2 — GET /events (if the ERP is installed on-premises, with no open port):

curl "https://beta.hapee.ai/api/v1/events?cursor=10432&limit=100" \
  -H "Authorization: Bearer hp_test_YOUR_CREDENTIAL"
{
  "data": [
    {"id": "evt_10433", "type": "deal.won", "api_version": "v1",
     "created_at": "2026-09-10T14:02:11Z", "subaccount_id": 63,
     "data": {"id": 5521, "title": "Q4 restock",
              "amount": "4200000", "currency": "CLP"}}
  ],
  "next_cursor": "10433",
  "has_more": false
}

Save the next_cursor and pass it in the next call. Events come oldest to newest on purpose: you are catching up, and they must be processed in the order they happened. Retention: 30 days.

Flow C — Deal won → invoice

The flow that justifies the integration.

  Hapee                          Your ERP
    │
    │  The team marks the deal as won
    │
    ├──── deal.won ─────────────────►│
    │     (webhook or /events)       │
    │                                │  Creates the sales order
    │                                │  Issues the DTE with the SII
    │                                │
    │◄─── PATCH /deals/{id} ─────────┤  Writes the folio back
    │       invoice_number: "F-8842" │
    │       payment_status: "pending"│
    │                                │
    │◄─── POST custom-objects/…  ────┤  Publishes the document as
    │                                │  a record visible in the CRM
    │
    │  The sales rep SEES the invoice inside the customer record

Step 1 — You receive the event

deal.won carries the complete deal, with the same shape that GET /deals/{id} returns.

Step 2 — You write back

curl -X PATCH https://beta.hapee.ai/api/v1/deals/5521 \
  -H "Authorization: Bearer hp_test_YOUR_CREDENTIAL" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: folio-F-8842" \
  -d '{"custom_fields": {
        "invoice_number": "F-8842",
        "payment_status": "pending",
        "due_date": "2026-10-10"}}'

Step 3 — You publish the document as a record

That way the sales rep sees the invoice without leaving Hapee:

curl -X POST https://beta.hapee.ai/api/v1/custom-objects/{object_id}/records \
  -H "Authorization: Bearer hp_test_YOUR_CREDENTIAL" \
  -H "Content-Type: application/json" \
  -d '{
    "external_id": "ERP-DTE-8842",
    "values": {
      "folio": "F-8842", "tipo_dte": "33",
      "monto_neto": "3529412", "iva": "670588", "total": "4200000",
      "moneda": "CLP", "fecha_emision": "2026-09-10",
      "estado": "emitida",
      "url_pdf": "https://erp.yourcompany.com/dte/8842.pdf"
    }
  }'

Flow D — Collections

# 1. The ERP reports that the document is overdue and unpaid
curl -X PATCH https://beta.hapee.ai/api/v1/deals/5521 \
  -H "Authorization: Bearer hp_test_YOUR_CREDENTIAL" \
  -d '{"custom_fields": {"payment_status": "overdue", "amount_paid": "0"}}'

# 2. The contact is tagged
curl -X POST https://beta.hapee.ai/api/v1/contacts/8821/tags \
  -H "Authorization: Bearer hp_test_YOUR_CREDENTIAL" \
  -d '{"tag": "cobranza-vencida"}'

# 3. The contact is enrolled in the collections workflow
curl -X POST https://beta.hapee.ai/api/v1/workflows/44/enroll \
  -H "Authorization: Bearer hp_test_YOUR_CREDENTIAL" \
  -d '{"contacto_id": 8821}'

From there, Hapee takes over: the reminders, WhatsApp, email. When the customer pays, the ERP closes the loop with payment_status: "paid" and the tag is removed.

Field mapping

A template to fill in with your team. The Hapee fields are the ones the API exposes today.

Company / Customer

Your ERPHapeeNotes
(internal id)external_idUnique per subaccount. It is the sync key
RUTrutThe check digit is validated. Stored as 12345678-K
Legal namerazon_social
Trade namenombreRequired on create
Line of businesscustom_fields.giro
Billing emailcustom_fields.billing_emailDifferent from email
Payment termscustom_fields.payment_terms
Addressdireccion, ciudad, region

Contact / Person

Your ERPHapeeNotes
(internal id)external_idUnique per subaccount
RUTrutOptional; can repeat across contacts
First name / Last namefirst_name / last_name
EmailemailLowercase
PhonephoneE.164: +56912345678
Companyempresa_idThe Hapee id, not your ERP's

Deal / Sales order

Your ERPHapeeNotes
Sales order no.external_id
DTE foliocustom_fields.invoice_number
Payment statuscustom_fields.payment_statuspending / paid / overdue
Amount paidcustom_fields.amount_paidDecimal string
Due datecustom_fields.due_dateYYYY-MM-DD
Amountamount + currencyDecimal string, not a number

Production go-live checklist

Before swapping hp_test_ for hp_live_:

Switching to production

  1. Ask the agency for an hp_live_ credential for the real subaccount
  2. Change the credential and the webhook URL
  3. Run the initial load once
  4. Check with GET /events that events are arriving

Nothing else needs to change. The sandbox and production speak the same API.

Common errors

CodeWhat happenedWhat to do
401 invalid_keyThe credential doesn't exist or was revokedRequest a new one
403 ip_no_permitidaYour IP is not on the allowlistAsk for it to be added
403 no_es_sandboxhp_test_ credential on a real subaccountUse hp_live_
404 not_foundIt doesn't exist, or it isn't in your subaccountCheck the id and the credential
409 idempotency_key_reusadaSame key, different bodyUse a new key
422 validation_errorLook at details[]: it names the field and the problem
429You went over the limitWait as long as Retry-After says

Always save the X-Request-Id from the response. It lets us find your exact call in our logs. Write to us at info@hapee.ai.