npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

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 CMS workflows collection steps, with custom field components for live DocType and target field selection.
  • Dead-Letter Queue (erpnext-dead-letters collection) 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-rules collection) 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_change for form-submissions) rather than a dedicated parallel engine.


Table of Contents


Installation

npm install payload-erpnext-plugin
# or
pnpm add payload-erpnext-plugin

Quick 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., POST to create, GET to 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:

  1. The plugin logs in to ERPNext via POST /api/method/login using the entered credentials (this call is never stored — only the resulting session is used, transiently, for the next steps).
  2. 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 via POST /api/resource/OAuth Client with skip_authorization: 1 set, so the end user is never shown a consent screen.
  3. It calls ERPNext's /authorize endpoint server-side using that session. Because skip_authorization is set, ERPNext auto-approves and redirects in a single hop straight to the plugin's redirect_uri with ?code=&state= — there's no separate "approve" confirmation redirect to follow, and that URL is only ever read off the Location header, never actually fetched (it isn't a route the plugin serves). The code is then exchanged for tokens.
  4. 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).
  5. 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 filters and fields target 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_nametitle | | 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 in errors. 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_KEY to a 32-byte hex key.
  • Constant-Time Secret Comparison: Internal secrets (INTERNAL_API_SECRET, ERPNEXT_PROXY_KEY) are compared with timingSafeEqual to prevent timing-oracle attacks.
  • Cross-Tenant Isolation: In multi-tenant environments, the proxy restricts list fetches and single resource requests to the company configured on the tenant's active ERPNext Config. Admin endpoints such as /api/erpnext-doctypes, /api/erpnext-doctype-fields, and /api/erpnext-config/fetch-companies further restrict non-super-admins to their assigned site.
  • 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 sites collection or configured in TRUSTED_ORIGINS / CORS_ORIGINS.
  • Rate Limiting: Public endpoints are rate-limited per IP using Redis when REDIS_URL is 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