payload-erpnext-plugin
v2.4.3
Published
Payload CMS plugin for ERPNext integration. Multi-doctype form workflows, credential management, proxy endpoints, and async job forwarding.
Maintainers
Readme
payload-erpnext-plugin
A self-contained Payload CMS 3.x plugin for secure, multi-tenant ERPNext integrations.
It provides:
- ERPNext Connection Configuration per site (multi-tenant) with encrypted credentials.
- Generic ERP Action Handlers (
erp-get,erp-post,erp-patch,erp-delete) registered directly into the CMS workflow engine's action registry. - Workflow Step Integration — injects a
trigger_erp(Trigger ERP Action) block into the CMSworkflowscollection steps, with custom field components for live DocType and target field selection. - Dead-Letter Queue (
erpnext-dead-letterscollection) for permanently failed ERPNext requests. - Anonymous File Upload endpoint for form attachments (e.g., resumes) with file-type and origin validation.
- Secure ERPNext Proxy Endpoints (
/api/erpnext-proxy/...) with rate-limiting, origin validation, and cross-tenant data isolation. - Inbound Sync Rules (
erpnext-sync-rulescollection) to map ERPNext DocTypes into Payload collections. - Inbound Webhook Receiver (
/api/erpnext-sync) with HMAC-SHA256 signature verification for ERPNext → Payload sync. - Webhook Signature Verification helper for validating incoming ERPNext/Frappe notifications.
[!NOTE] Form-submission-to-ERPNext forwarding is handled by the host application's general Workflows collection (e.g., triggered on
collection_changeforform-submissions) rather than a dedicated parallel engine.
Table of Contents
- Installation
- Quick Start
- ERPNext OAuth2 Connect
- Configuration
- ERPNext Workflow Steps
- Inbound Sync Rules
- Security & Access Control
- Dead-Letter Queue
- Endpoints
- Architecture
- License
Installation
npm install payload-erpnext-plugin
# or
pnpm add payload-erpnext-pluginQuick Start
1. Add the plugin to payload.config.ts
Import and register erpnextPlugin, passing the host application's action registry to enable workflow execution:
import { buildConfig } from 'payload'
import { erpnextPlugin } from 'payload-erpnext-plugin'
import { actionRegistry } from './lib/actionRegistry' // your host application's registry
export default buildConfig({
// ... your config
plugins: [
erpnextPlugin({
registry: actionRegistry,
}),
],
})2. Create an ERPNext Config in the Payload Admin
Go to Integrations → ERPNext Config:
- Select the site/tenant.
- Enter the ERPNext URL (HTTPS only).
- Either enter API Key and Secret manually, or enter the ERPNext username/password of an account with permission to create OAuth Clients and click Connect — see ERPNext OAuth2 Connect below. Both populate the same underlying credentials; pick whichever suits a given site.
- Companies are fetched automatically once credentials are in place (manual or OAuth) — no separate "fetch companies" step.
- Mark as Active and Save.
3. Build a Workflow Step
Go to Settings → Workflows:
- Create or edit a workflow.
- In Steps, add a Trigger ERP Action (
trigger_erp) block. - Select the DocType and Action (e.g.,
POSTto create,GETto search). - Map fields from the CMS document context using
{{doc.fieldName}}variables.
ERPNext OAuth2 Connect
Two ways to authenticate an ERPNext Config, fully interchangeable — pick whichever suits a given site. Both populate the credentials getCredentials()/authHeaders() use, so nothing downstream (the proxy endpoints, workflow ERP actions) needs to know which was used.
Option A — Manual API Key/Secret
Create an API Key/Secret pair in ERPNext under User → API Access, paste them in. Fully supported indefinitely — not a legacy fallback.
Option B — Connect (login-based auto-connect)
There is no manual OAuth Client setup step — no going into ERPNext's admin to register a Client ID/Secret or a redirect URI by hand. Enter the ERPNext username/password of an account with permission to create OAuth Clients (typically an Administrator or System Manager) directly on the erpnext-config document and click Connect:
- The plugin logs in to ERPNext via
POST /api/method/loginusing the entered credentials (this call is never stored — only the resulting session is used, transiently, for the next steps). - It looks up an existing OAuth Client via
GET /api/resource/OAuth Client, keyed by a deterministic name (IVarse Integration (<site-slug>)) — reused if found, so reconnecting the same site never creates a duplicate Client. If none exists, it creates one viaPOST /api/resource/OAuth Clientwithskip_authorization: 1set, so the end user is never shown a consent screen. - It calls ERPNext's
/authorizeendpoint server-side using that session. Becauseskip_authorizationis set, ERPNext auto-approves and redirects in a single hop straight to the plugin'sredirect_uriwith?code=&state=— there's no separate "approve" confirmation redirect to follow, and that URL is only ever read off theLocationheader, never actually fetched (it isn't a route the plugin serves). The code is then exchanged for tokens. - The resulting Client ID and Client Secret (auto-populated, read-only, masked in the UI — the user never sees or types these) and the access/refresh token pair are stored (encrypted).
- Access tokens are refreshed automatically and transparently by
getCredentials()when expired, using the stored refresh token — no user interaction needed after the initial connect.
Manual API Key/Secret fields are not required (and are not cleared) once a config is OAuth-connected — switching back to manual entry is just a matter of leaving OAuth alone and filling those fields in; authMethod only reflects whichever path was used most recently.
The idempotency guarantee (point 2 above) is the reason this is safe to re-run: if an erpnext-config document is ever deleted and recreated for the same site, reconnecting finds and reuses the same OAuth Client by name instead of accumulating orphaned, unused Clients in ERPNext.
Why This Flow Doesn't Need OAuth state Param Signing
A state param exists to protect OAuth flows that redirect the browser out to a third-party authorization server and back — it's what lets the app verify, statelessly, that the request completing the round trip is the same one that started it. ERPNext's auto-connect has no such round trip to protect: it's a single same-origin POST /api/erpnext-oauth/auto-connect, and the browser never leaves the Payload admin. It's protected instead by: requiring an authenticated admin/super-admin Payload session (isAdminOrAbove), and a per-IP rate limit (5 attempts per 60 seconds) against credential-stuffing the ERPNext login step.
Configuration
Plugin Options
import { buildConfig } from 'payload'
import { erpnextPlugin } from 'payload-erpnext-plugin'
import { actionRegistry, emitSystemEvent, systemEvents, isInternalAuth } from './lib/host' // your host app
export default buildConfig({
// ... your config
plugins: [
erpnextPlugin({
registry: actionRegistry, // required for workflow ERP actions
host: {
emitSystemEvent, // optional: enables ERPNext connection monitoring
systemEvents, // optional: { ERPNEXT_CONNECTION_FAILED, ERPNEXT_CONNECTION_RESTORED }
isInternalAuth, // optional: enables /api/erpnext/link-customer
siteCollectionsMap, // optional: enables global/local grouping in the Sync Rules collection picker
},
enableAnonymousUpload: true, // optional, defaults to true
}),
],
})| Option | Type | Default | Description |
|--------|------|---------|-------------|
| registry | ActionRegistryRef | undefined | Required for workflow actions. The action registry to register erp-get, erp-post, erp-patch, and erp-delete handlers. |
| host | ERPNextHostBindings | undefined | Host-injected automation primitives (emitSystemEvent, systemEvents, isInternalAuth, siteCollectionsMap). Keeps the plugin free of circular imports into the CMS — see Inbound Sync Rules for what siteCollectionsMap does. |
| enableAnonymousUpload | boolean | true | Registers /api/anonymous-upload for form file attachments. |
Environment Variables
| Variable | Required | Description |
|----------|----------|-------------|
| ERPNEXT_ENCRYPTION_KEY | Required | 32-byte hex AES-256-GCM key for encrypting credentials at rest. Generate with openssl rand -hex 32. |
| INTERNAL_API_SECRET | Recommended | Shared secret for isInternalAuth checks (e.g. /api/erpnext/link-customer). Verified with constant-time comparison. |
| ERPNEXT_PROXY_KEY | Recommended | Shared secret for server-to-server proxy access (x-internal-key header). Verified with constant-time comparison. |
| ERPNEXT_PROXY_REQUIRE_CAPTCHA | Optional | Set to true to require a reCAPTCHA token on public write operations (/api/erpnext-proxy/submit). |
| REDIS_URL | Optional | Enables Redis-backed rate limiting. Recommended for horizontal scaling. |
| TRUSTED_ORIGINS | Optional | Comma-separated list of origins allowed for proxy and anonymous upload endpoints. |
| CORS_ORIGINS | Optional | Fallback origin allow-list for proxy and anonymous upload endpoints. |
| PAYLOAD_PUBLIC_SERVER_URL | Recommended | The CMS public URL; used as a trusted origin for anonymous uploads, and as the redirect_uri registered on each auto-created ERPNext OAuth Client. That URI is never actually hit by a browser (see ERPNext OAuth2 Connect — the whole exchange happens server-side), it just has to match what ERPNext's OAuth2 provider has on file for the request to be accepted. |
| NEXT_PUBLIC_PAYLOAD_URL | Optional | Fallback public URL for anonymous upload origin checks. |
| TRUSTED_PROXY_COUNT | Optional | Number of trusted proxies in front of the app, used for accurate IP extraction. |
| ALLOW_PLAINTEXT_ERPNEXT_CREDS | Optional | Dangerous opt-out: allows storing ERPNext credentials without encryption when no ERPNEXT_ENCRYPTION_KEY is set. Not recommended for production. |
ERPNext Workflow Steps
The plugin automatically extends the host's workflows collection steps with the Trigger ERP Action (trigger_erp) block.
Step Fields
| Field | Description |
|-------|-------------|
| DocType | ERPNext DocType (fetched live using a custom ERPNextDocTypeSelect component — paginated, with a "Load more" row inside the dropdown itself for ERPNext instances with more DocTypes than fit in one batch; scrolling to the bottom of the list also loads the next page automatically). |
| Action | Read / Search (GET), Create (POST), Update (PUT), or Delete (DELETE). |
| Result Key | The namespace prefix for output variables (e.g. erp → {{erp_name}}, {{erp_result}}). Prevents overwriting earlier step contexts. |
| Field Mappings | Map target fields to source values. The target field input uses ERPNextTargetFieldSelect to display fields fetched dynamically from the selected DocType. |
Field Mapping Rules
- For GET Actions: Map source expressions into
filtersandfieldstarget keys. E.g.filters→[["phone", "=", "{{doc.phone}}"]]fields→["name", "customer_name", "status"]
- For POST/PUT Actions: Map target ERPNext field names to values or variables (e.g.
customer_name→{{doc.fullName}}).
Inbound Sync Rules
The erpnext-sync-rules collection lets you map ERPNext DocType changes into Payload documents. Each rule is organized into four tabs:
| Tab | Fields | Description |
|-----|--------|-------------|
| 🔗 Mapping | DocType, Target Collection | Which ERPNext DocType this rule watches, and which Payload collection it writes into. The collection picker is site-scoped: selecting a site shows only collections that site actually uses, grouped under Local (this site only) and Global (shared across sites) headings — it no longer lists every collection in the system regardless of site, and won't let you accidentally point one site's rule at another site's local collection. Requires siteCollectionsMap passed to the plugin (see Configuration); falls back to an ungrouped list if omitted. |
| 🗺️ Field Mappings | Field Mappings (array), Convert Value | Map ERPNext fields to Payload fields. Exactly one row must be ticked Is Upsert Key — that field's value is used to look up an existing Payload document before deciding whether to create or update, preventing duplicates on repeat syncs. This replaced two separate standalone "upsert ERP field"/"upsert Payload field" text inputs — the truth now lives in one place, inside the mapping table itself, instead of needing to be kept in sync with it by hand. |
| ⚙️ Advanced | Constant Values, Sync Timestamp, Status Sync, Customer-Group Promotion | Sync Timestamp names a date field stamped on every successful upsert, updates included (see Sync timestamp). Constant Values sets fixed fields on every synced record (e.g. a source tag). Status Sync (optional — leave Status Field blank to turn it off) writes a mapped Payload status value whenever the ERPNext record's status matches a configured Status Mapping row; each row can also optionally promote the ERPNext customer to a different Customer Group (fetched live from ERPNext via ERPNextCustomerGroupSelect) — not one fixed group for the whole rule, a different group per status if needed. Both apply uniformly whether the sync came from the live webhook or a backfill. |
| 📥 Backfill | Backfill Filter, Backfill On Save | An ERPNext REST filter (JSON) controlling which existing records a backfill pulls — e.g. skip disabled records or variant templates. Backfill runs automatically after save when Backfill On Save is ticked; it does not affect what the live webhook receives. |
Value transforms
ERPNext carries display names — never slugs, never Payload document IDs — so a verbatim copy cannot satisfy a required slug field or a relationship field. Each mapping row therefore has a Convert Value setting:
| Transform | Use for | Example |
|-----------|---------|---------|
| none (default) | Everything else. Copies the ERPNext value unchanged. | item_name → title |
| strip_html | A plain text/textarea target fed by a Frappe rich-text field. ERPNext returns those as HTML, so a verbatim copy stores markup in a plain-text field. | Item description <div><p>A timeless classic…</p></div> → description A timeless classic… |
| slugify | A required slug field with no ERPNext counterpart. | Item Group name "Exotic Signature Mixes" → slug exotic-signature-mixes |
| link | A relationship field. Prefer this. Pick only the Payload field — the collection comes from the field's relationTo and the match field from that collection's admin.useAsTitle. | Item item_group "Cocktails" → category → id of the catalogue-categories doc named "Cocktails" |
| lookup | The manual form of link: name the collection and field yourself. For polymorphic relationships, or to match on a field other than the title. | as above, with Look Up In Collection and Match Against Field typed in |
Why link exists
ERPNext and Payload disagree about what a foreign key is. Frappe has no numeric IDs — a
document's primary key is its docname — so a Link field stores "Cocktails". Payload
stores an integer row ID and keeps the name in a separate column. Copying one into the
other is always invalid, so the value has to be resolved at sync time.
Both settings a resolution needs are already in Payload's config: relationTo on the
relationship field, and useAsTitle on the collection it points at. link reads them.
lookup asks an operator to retype them, which is how a rule ends up matching against a
relationship field — Payload coerces the string to an ID, producing NaN, and Postgres
rejects the query for every record in the backfill.
link refuses rather than guesses when the config is ambiguous or unusable: a
polymorphic relationTo, a target that is not a relationship, a collection with no
useAsTitle, or a useAsTitle that resolves to id or any non-text field. Each names
the rule and field in the log.
When the target does not exist yet
By default an unresolved link leaves the field unset, so a required relationship fails the record. That makes rule ORDER load-bearing: on a fresh site the Item Group rule has to be saved before the Item rule, and an Item webhook arriving before its Item Group has ever synced fails permanently.
Tick Create it if missing on the row to close that. The target is created from the
ERPNext value — Frappe's Link field guarantees it exists upstream, so its absence in
Payload is a gap in what has been synced, not bad data. Only derivable fields are
filled: the matched field, a required slug, the site, and the site's organization.
Anything else the collection requires is left for Payload to reject, and the error names
it so you can add a Constant Value.
It is off by default because creating records nobody asked for is how a catalogue ends up holding ERPNext scaffolding such as "All Item Groups" and "Raw Material".
Lookups are scoped to the rule's own site whenever the looked-up collection has a site field, so one tenant's records can never link to another tenant's documents. A lookup that matches nothing leaves the field unset and logs a warning rather than writing a bad reference — if the field is required, the record then fails validation and is counted in failed.
Transforms also apply to the upsert key: if the key row is name → slug with slugify, matching is done on the slug, because that is what is stored.
Sync timestamp
Sync Timestamp — Payload Field (⚙️ Advanced) names a date field stamped on every successful upsert, updates included. This is when this system last pulled the record, which is deliberately different from ERPNext's own modified date — map that as a normal field if you want it too. Stamping on update is the point: an already-existing document is exactly where you need to see that the ERPNext link is live.
Reading backfill results
lastBackfillStats distinguishes two outcomes that used to share one counter:
skipped— nothing was attempted: the rule has no upsert key, or the record had no value for it.failed— a write was attempted and rejected, with up to five distinct reasons inerrors. A required target field the mapping does not supply is the usual cause.
A rule that cannot write a single record reports failed, not skipped.
Use the /api/erpnext-sync?site=<site-slug> endpoint as the webhook target in ERPNext/Frappe. The endpoint verifies the webhook signature using the configured webhookSecret on the erpnext-config document — a site can have multiple active rules (different DocTypes, or multiple rules for the same DocType), all matching rules for the incoming DocType are applied.
Security & Access Control
- HTTPS in Production: The proxy and fetch endpoints refuse to forward credentials or request payloads to non-HTTPS ERPNext URLs when
NODE_ENV === 'production'. Plain HTTP is allowed only in development. - Credential Encryption: Secrets and keys are encrypted using AES-256-GCM before writing to the database. They are only decrypted in memory during execution. Set
ERPNEXT_ENCRYPTION_KEYto a 32-byte hex key. - Constant-Time Secret Comparison: Internal secrets (
INTERNAL_API_SECRET,ERPNEXT_PROXY_KEY) are compared withtimingSafeEqualto prevent timing-oracle attacks. - Cross-Tenant Isolation: In multi-tenant environments, the proxy restricts list fetches and single resource requests to the
companyconfigured on the tenant's activeERPNext Config. Admin endpoints such as/api/erpnext-doctypes,/api/erpnext-doctype-fields, and/api/erpnext-config/fetch-companiesfurther restrict non-super-admins to their assignedsite. - Doctype Whitelisting: Frontend clients accessing the proxy can only query whitelisted, non-sensitive doctypes (e.g.,
Job Opening,Blog Category). - Origin Validation: Proxy and anonymous upload endpoints reject browser requests unless the origin matches a domain registered in the
sitescollection or configured inTRUSTED_ORIGINS/CORS_ORIGINS. - Rate Limiting: Public endpoints are rate-limited per IP using Redis when
REDIS_URLis set, with an in-memory fallback. - Least-Privilege Access Control: Collection access helpers (
siteScopedRead,siteScopedCreate,siteScopedUpdate,siteScopedDelete) ensure documents can only be read, created, updated, or deleted within the user's site unless the user is a super-admin.
Dead-Letter Queue
Failed ERPNext calls from workflows are written to the erpnext-dead-letters collection:
- Original Context: Submission ID, site, URL, DocType, and the request payload.
- Diagnostics: Error category (validation, connection, timeout), details, HTTP status code, retry count, and workflow correlation ID.
- Recovery: Administrators can review, debug, and trigger manual retries via the Retry Dead Letters endpoint.
Endpoints
All admin endpoints require super-admin or admin authentication.
| Method | Path | Description |
|--------|------|-------------|
| POST | /api/erpnext-proxy/submit | Proxies creation of whitelisted DocTypes (e.g. Leads) from frontends. |
| GET | /api/erpnext-proxy/resource | Proxies reading/filtering of whitelisted DocTypes. |
| GET | /api/erpnext-proxy/health | Verifies ERPNext API credentials and connection. |
| POST | /api/erpnext-proxy/upload | Proxies file uploads to ERPNext. |
| POST | /api/erpnext-config/fetch-companies | Live dropdown population: fetches ERPNext Company list. |
| GET | /api/erpnext-doctypes | Live dropdown population: fetches ERPNext DocType list, paginated (limitStart query param; response includes hasMore/nextLimitStart) so instances with more DocTypes than a single batch aren't silently truncated. |
| GET | /api/erpnext-doctype-fields | Live dropdown population: fetches fields for a selected DocType, by reading the DocType document's own fields child table — not a direct DocField list query, which most ERPNext API roles don't have permission to read. |
| GET | /api/cms-collections?siteId=<id> | Lists writable Payload CMS collections (admin). With siteId, response is grouped into local (this site) vs. global (shared) collections using siteCollectionsMap — see Inbound Sync Rules. Without it, falls back to an ungrouped list. |
| GET | /api/cms-collection-fields | Lists fields for a Payload CMS collection (admin). |
| GET | /api/erpnext-customer-groups?siteId=<id> | Live dropdown population: fetches ERPNext Customer Group list for the Customer-Group Promotion field on Sync Rules. |
| POST | /api/erpnext/retry-dead-letters | Retries dead-letter queue items. |
| POST | /api/anonymous-upload | Anonymous file upload to Payload Media. |
| POST | /api/erpnext-sync?site=<site-slug> | Inbound ERPNext/Frappe webhook receiver — verifies the HMAC signature against webhookSecret, then applies every active erpnext-sync-rules row matching the incoming DocType (a site can sync multiple DocTypes to multiple collections; see Inbound Sync Rules). |
| POST | /api/erpnext/link-customer/:id | Internal-only link between Payload customer and ERPNext Customer. |
| POST | /api/erpnext-oauth/auto-connect | Login-based OAuth auto-connect — see ERPNext OAuth2 Connect. Admin-only, rate-limited (5/min/IP). |
Architecture
Workflow Trigger (e.g. collection change)
│
▼
Action Registry Lookup
│
┌──────────┴──────────┐
▼ ▼
Action Executed Action Fails (Transient)
(API Call Ok) │
│ ▼
│ Enqueue retry job
│ │
│ ┌────────┴────────┐
│ ▼ ▼
│ Retry Ok Exhausted (All retries fail)
│ │ │
▼ ▼ ▼
┌──────────────────────┐ ┌──────────────────────┐
│ Execution Completed │ │ Write to Dead Letter│
└──────────────────────┘ └──────────────────────┘License
MIT
