Key Takeaway
A custom ERP has to talk to payment gateways, logistics platforms, e-invoicing systems, government portals and marketplace APIs. Handling those through webhooks and a message queue rather than synchronous calls cuts integration latency from minutes to seconds, and keeps one slow third party from blocking a business transaction.
Modern ERP systems don't operate in isolation. They need to talk to payment gateways, logistics platforms, e-invoicing systems, government portals, and marketplace APIs. Event-driven architecture with webhooks and message queues can reduce integration latency from minutes to seconds, according to API integration research. In Indonesia, this is particularly relevant: ERP systems need to connect to DJP's e-Faktur for VAT invoices, BPJS for social security reporting, and local payment gateways like Midtrans or Xendit. In my freelance ERP consulting work, I've integrated over a dozen external APIs into the custom ERP systems I build for clients. This post covers the patterns and pitfalls.
The Three Integration Patterns
Every ERP-to-external integration falls into one of three patterns: synchronous REST API calls (ERP calls external API and waits for response), asynchronous webhooks (external system notifies ERP when something happens), and file-based batch exchange (daily file drops, CSV imports, SFTP). Each pattern has appropriate use cases. Synchronous is right for real-time queries (check a payment status). Webhooks are right for event notifications (payment confirmed, delivery status changed). Batch is right for high-volume data that doesn't require real-time updates (daily transaction reconciliation).
Outbound Integration: Calling External APIs
For outbound API calls from NestJS, I use a dedicated IntegrationService per external system. Each service handles: authentication (API keys stored in environment variables, OAuth token refresh if needed), request building, response parsing, error classification (retry-able vs. permanent errors), and logging. I wrap all external calls in a Circuit Breaker pattern (using the opossum library) — if the external API fails repeatedly, the circuit opens and returns a cached or default response instead of hammering a failing service.
ERP Third-Party Integration Architecture
┌──────────────────────────────────────────────────────┐
│ ERP (NestJS) │
│ │
│ Business Logic → IntegrationModule │
│ │ │
│ ┌──────────────┼──────────────┐ │
│ ▼ ▼ ▼ │
│ MidtransAdapter EFakturAdapter BpjsAdapter │
│ (payment gw) (DJP SOAP) (REST API) │
└──────────┬───────────────┬───────────┬───────────────┘
│ │ │
OUTBOUND ▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────┐
│ Midtrans │ │ DJP e-Faktur│ │ BPJS │
│ Payment GW │ │ SOAP API │ │ REST API│
└──────────────┘ └──────────────┘ └──────────┘
INBOUND WEBHOOKS:
External System → POST /webhooks/{provider}
│
▼ (< 200ms — quick return)
┌────────────────────────────────────┐
│ Validate signature │
│ Create Bull job │
│ Return HTTP 200 immediately │
└──────────────────┬─────────────────┘
│
▼
┌────────────────────────────────────┐
│ Bull Worker (async) │
│ Process event │
│ Update ERP state │
│ Retry on failure (exponential) │
│ → Dead Letter Queue after max retries │
└────────────────────────────────────┘From my experience as a freelance ERP consultant: never call an external API synchronously from a user request if the response is non-critical or can be retried. For example, when posting a payment, don't make the user wait for the payment gateway response — submit the payment to a Bull job queue, return an optimistic 'payment submitted' response to the user, and process the gateway call asynchronously. This keeps the ERP UI responsive even when external services are slow.
Inbound Webhooks: Receiving External Events
Webhooks from external systems arrive at a NestJS endpoint as HTTP POST requests. The critical implementation requirements: validate the webhook signature (each provider has a different signing mechanism — Midtrans uses an MD5 hash, Xendit uses x-callback-token header, DHL uses HMAC-SHA256), make the handler idempotent (duplicate webhooks must not create duplicate records), and return 200 quickly (never do heavy processing synchronously in a webhook handler — push to a queue and return 200 within 2 seconds or the sender will retry).
Message Queue Architecture for Reliable Integration
I use Bull (Redis-backed job queue) for all asynchronous integration processing. When a webhook arrives, the handler validates the signature, creates a job in Bull, and returns 200. The Bull worker processes the job: parses the event, updates ERP state, triggers downstream actions (e.g., mark invoice paid, send notification). If the processing fails, Bull retries with exponential backoff. After a configurable number of retries, jobs go to a dead-letter queue for manual review. This architecture means no webhook event is ever lost, even if the ERP is temporarily down when it arrives.
// NestJS: Uniform Integration Adapter Interface
export interface IntegrationAdapter {
connect(): Promise<void>;
healthCheck(): Promise<boolean>;
send(payload: unknown): Promise<{ referenceId: string }>;
getStatus(referenceId: string): Promise<{ status: string }>;
}
// Example: Midtrans adapter
@Injectable()
export class MidtransAdapter implements IntegrationAdapter {
private breaker: CircuitBreaker;
constructor() {
this.breaker = new CircuitBreaker(this.callMidtrans.bind(this), {
timeout: 5000, // 5s timeout
errorThresholdPercentage: 50,
resetTimeout: 30000, // 30s before retry after open
});
}
async send(payload: CreatePaymentPayload) {
return this.breaker.fire(payload);
}
private async callMidtrans(payload: CreatePaymentPayload) {
const res = await fetch('https://api.midtrans.com/v2/charge', {
method: 'POST',
headers: {
'Authorization': 'Basic ' + Buffer.from(
process.env.MIDTRANS_SERVER_KEY + ':'
).toString('base64'),
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`Midtrans error: ${res.status}`);
return res.json();
}
}
// Bull queue: reliable webhook processing
@Processor('webhook-events')
export class WebhookProcessor {
@Process('midtrans-payment')
async handleMidtransPayment(@InjectQueue() job: Job<MidtransEvent>) {
// Idempotent: skip if already processed
const exists = await this.paymentRepo.findByReference(job.data.transaction_id);
if (exists) return;
await this.arService.recordPayment(job.data);
this.logger.log(`Payment processed: ${job.data.transaction_id}`);
}
}How I Approached This
My integration layer is a dedicated NestJS module with adapters per external system. The adapter interface is: connect(), healthCheck(), send(payload), and getStatus(referenceId). This uniform interface makes it easy to swap providers — I switched from one SMS gateway to another without changing any business logic, only the adapter implementation. The adapters are registered as NestJS providers with a symbol key, and the business services inject them by interface rather than concrete class.
External APIs have rate limits. DJP's e-Faktur API has a per-hour limit that can easily be exceeded during end-of-month VAT invoice generation when many invoices are submitted at once. I batch e-Faktur submissions with a rate limiter (using bottleneck library in Node.js) to stay within limits. Separately: API credentials expire or get rotated. If you store API keys only in environment variables, a key rotation requires a server restart. Use a secrets management service (AWS Secrets Manager, HashiCorp Vault) or at minimum a database table for credentials with a reload mechanism.
Indonesian-Specific Integrations
The integrations specific to Indonesian business context: DJP e-Faktur (PPN invoice submission via SOAP API — yes, it's SOAP, not REST), BPJS API (monthly contribution reporting), Bank Indonesia payment gateway APIs (SNAP — Standard National Open API for banking interoperability), and LKPP (government procurement portal) for companies that sell to government entities. Each of these has quirks: e-Faktur uses a certificate-based authentication and a SOAP envelope format; SNAP requires an HMAC-SHA512 signature with a specific timestamp format. Document these integration details thoroughly — they're poorly documented by the Indonesian government.
Monitoring and Alerting for Integrations
Integration health monitoring is critical. I expose a /health/integrations endpoint that pings each external service and returns green/amber/red status. This is polled by my monitoring stack every 5 minutes. Failed integrations trigger an immediate Slack alert so I can respond right away. I also maintain an integration_events table: every outbound call and inbound webhook is logged with the external reference ID, payload hash, response status, and processing time. This log is indispensable when debugging integration discrepancies — 'did I actually send that e-Faktur?' is answered by querying the table.
