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:
- 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.
- 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. - 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 do | Path | Who does it |
|---|---|---|
| Load thousands of customers and products | API + batch | Your technical team |
| “Deal won → notify the ERP” | Workflow | Your sales team, no code |
| “The ERP says it was paid” | Webhook trigger | One HTTP call, no credential |
| Incremental sync without losing anything | API + webhooks | Your 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:
- “Outgoing webhook” node — placed after the step you care about
(for example, “the deal moved to Won”) and makes a
POSTto your URL. You configure the URL, method, JSON body with contact variables, headers and a secret for signing. - “Webhook received” trigger — gives you a URL that your system
POSTs to in order to start the workflow. It needs no credential: the token in the URL itself is enough.
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
| Workflow | API + webhooks | |
|---|---|---|
| Retries if your system is down | 5 attempts, ~1 hour | 12 attempts, ~24 hours |
| Queryable delivery log | In the workflow log | Yes, with manual redelivery |
| A 4xx from your side | Carries on as if nothing happened | Retried and logged |
| Signature | Optional secret you configure | HMAC with timestamp |
| Bulk load | No | Up to 100 per call |
| Who sets it up | The sales team | The 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:
| Entity | Owner | Reason |
|---|---|---|
| Tax data (RUT, legal name, line of business) | ERP | It is what goes on the DTE |
| Products and list prices | ERP | It is where the catalog is maintained |
| Invoices, credit notes, payments | ERP | The SII only knows the ERP |
| Deal stage, sales activity | Hapee | It is where the sales team works |
| Contacts and their conversation history | Hapee | It is where they happen |
| Contact data (phone, email) | Whoever touched it last | Both 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
- Always send
external_idwith your ERP's id. It is what makes the load repeatable without duplicating: the second run updates instead of creating ("created": false). - The
job_idlets you re-read the result withGET /jobs/{id}if you lose the response. A 100-itemPOSTwhose result is lost to a network drop can't be blindly repeated; with the job, you look it up. - Load companies before contacts, so you can link them.
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 ERP | Hapee | Notes |
|---|---|---|
| (internal id) | external_id | Unique per subaccount. It is the sync key |
| RUT | rut | The check digit is validated. Stored as 12345678-K |
| Legal name | razon_social | |
| Trade name | nombre | Required on create |
| Line of business | custom_fields.giro | |
| Billing email | custom_fields.billing_email | Different from email |
| Payment terms | custom_fields.payment_terms | |
| Address | direccion, ciudad, region |
Contact / Person
| Your ERP | Hapee | Notes |
|---|---|---|
| (internal id) | external_id | Unique per subaccount |
| RUT | rut | Optional; can repeat across contacts |
| First name / Last name | first_name / last_name | |
email | Lowercase | |
| Phone | phone | E.164: +56912345678 |
| Company | empresa_id | The Hapee id, not your ERP's |
Deal / Sales order
| Your ERP | Hapee | Notes |
|---|---|---|
| Sales order no. | external_id | |
| DTE folio | custom_fields.invoice_number | |
| Payment status | custom_fields.payment_status | pending / paid / overdue |
| Amount paid | custom_fields.amount_paid | Decimal string |
| Due date | custom_fields.due_date | YYYY-MM-DD |
| Amount | amount + currency | Decimal string, not a number |
Production go-live checklist
Before swapping hp_test_ for hp_live_:
GET /meresponds and shows the correct subaccount- The initial load ran twice in the sandbox and duplicated nothing
- Every record has an
external_idwith your ERP's id - The RUTs pass validation (no
422in the batch) - The webhook endpoint verifies the signature and rejects an invalid one
- The receiver deduplicates by the event
id(order is not guaranteed and a delivery can repeat) - Every write sends
Idempotency-Key - A retry with the same key was tested and did not duplicate
- The complete Flow C works end to end in the sandbox
- The source of truth per entity is agreed
- There is a place where the ERP logs API errors, with the response's
request_id - If the ERP has a fixed IP, it is loaded into the credential's allowlist
Switching to production
- Ask the agency for an
hp_live_credential for the real subaccount - Change the credential and the webhook URL
- Run the initial load once
- Check with
GET /eventsthat events are arriving
Nothing else needs to change. The sandbox and production speak the same API.
Common errors
| Code | What happened | What to do |
|---|---|---|
401 invalid_key | The credential doesn't exist or was revoked | Request a new one |
403 ip_no_permitida | Your IP is not on the allowlist | Ask for it to be added |
403 no_es_sandbox | hp_test_ credential on a real subaccount | Use hp_live_ |
404 not_found | It doesn't exist, or it isn't in your subaccount | Check the id and the credential |
409 idempotency_key_reusada | Same key, different body | Use a new key |
422 validation_error | Look at details[]: it names the field and the problem | |
429 | You went over the limit | Wait 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.