Nango Function Builder
Build deployable Nango functions (actions and syncs) with repeatable patterns and validation steps.
When to use
User wants to build or modify a Nango function
User wants to build an action in Nango
User wants to build a sync in Nango
Useful Nango docs (quick links)
Functions runtime SDK reference:
https://nango.dev/docs/reference/functions
Implement an action:
https://nango.dev/docs/implementation-guides/use-cases/actions/implement-an-action
Implement a sync:
https://nango.dev/docs/implementation-guides/use-cases/syncs/implement-a-sync
Testing integrations (dryrun, --save, Vitest):
https://nango.dev/docs/implementation-guides/platform/functions/testing
Deletion detection (full vs incremental):
https://nango.dev/docs/implementation-guides/use-cases/syncs/deletion-detection
Nango HTTP API reference:
https://nango.dev/docs/reference/api
Nango API auth (secret key):
https://nango.dev/docs/reference/api/authentication
Nango API: Get connection & credentials:
https://nango.dev/docs/reference/api/connections/get
Proxy requests to external APIs:
https://nango.dev/docs/guides/primitives/proxy
Workflow (recommended)
Decide whether this is an action or a sync.
Gather required inputs (integration id, connection id, script name, and API docs/sample responses; actions: test input JSON). If you need connection details/credentials or want to do setup/discovery calls, use the Nango HTTP API (Connections/Proxy; auth with Nango secret key); do not invent Nango CLI token/connection commands.
Verify this is a Zero YAML TypeScript project (no
nango.yaml
) and you are in the Nango root (
.nango/
exists).
Compile as needed with
nango compile
(one-off).
Create/update the function file under
{integrationId}/actions/
or
{integrationId}/syncs/
.
Register the file in
index.ts
(side-effect import).
Validate with
nango dryrun ... --validate -e dev --no-interactive --auto-confirm
(actions: never omit
--input '{...}'
; use
--input '{}'
for no-input actions).
If validation can't pass, stop and return early stating the missing external state/inputs required (never hand-author/edit/rename/move
*.test.json
).
Ensure
/dev/null && echo "YAML PROJECT DETECTED" || echo "OK - No nango.yaml" If you see YAML PROJECT DETECTED: Stop immediately. Tell the user to upgrade to the TypeScript format first. Do not attempt to mix YAML and TypeScript. Reference: https://nango.dev/docs/implementation-guides/platform/migrations/migrate-to-zero-yaml Verify Nango Project Root Do not create files until you confirm the Nango root: ls -la .nango/ 2
/dev/null && pwd && echo "IN NANGO PROJECT ROOT" || echo "NOT in Nango root" If you see NOT in Nango root: cd into the directory that contains .nango/ Re-run the check Do not use absolute paths as a workaround All file paths must be relative to the Nango root. Creating files with extra prefixes while already in the Nango root will create nested directories that break the build. Project Structure and Naming ./ |-- .nango/ |-- index.ts |-- hubspot/ | |-- actions/ | |
-- create-contact.ts |-- syncs/ |-- fetch-contacts.ts-- slack/-- actions/-- post-message.ts Provider directories: lowercase (hubspot, slack) Action files: kebab-case (create-contact.ts) Sync files: kebab-case (many teams use a fetch- prefix, but it's optional) One function per file (action or sync) All actions and syncs must be imported in index.ts Register scripts in index.ts (required) Use side-effect imports only (no default/named imports). Include the .js extension. // index.ts import './github/actions/get-top-contributor.js' ; import './github/syncs/fetch-issues.js' ; Symptom of incorrect registration: the file compiles but you see No entry points found in index.ts... or the function never appears. Non-Negotiable Rules (Shared) Platform constraints (docs-backed) Zero YAML TypeScript projects do not use nango.yaml . Define functions with createAction() or createSync() . Register every action/sync in index.ts via side-effect import ( import './.js' ) or it will not load. You cannot install/import arbitrary third-party packages in Functions. Relative imports inside the Nango project are supported. Pre-included dependencies include zod , crypto / node:crypto , and url / node:url . Sync records must include a stable string id . Action outputs cannot exceed 2MB. deleteRecordsFromPreviousExecutions() is deprecated. For automated deletion detection in full refresh syncs, use trackDeletesStart() / trackDeletesEnd() and only call trackDeletesEnd() after the full dataset has been fetched and saved (do not swallow errors and still call it). HTTP request retries default to 0 . Set retries intentionally (and be careful retrying non-idempotent writes). Dryrun + tests (hard rules) Default dryruns to -e dev --no-interactive --auto-confirm (agents commonly run in non-interactive contexts). Actions: never omit --input in nango dryrun (use --input '{}' for empty input). Never hand-author, edit, rename, or move .test.json (including changing any recorded hash fields). Treat .test.json as generated, read-only artifacts. .test.json must be generated by nango dryrun .test.json to hard-code error payloads, HTML bodies, or modified URLs. Nango HTTP API (Connections + Proxy) (hard rules + cheat sheet) The Nango CLI is for local Functions development ( compile , dryrun , generate:tests ). For connection management, discovery, and calling provider APIs, use the Nango HTTP API (Proxy included). Do not guess/invent Nango CLI commands for tokens/connections (e.g., nango token , nango connection get ). If you need something, look it up in the Nango API reference: https://nango.dev/docs/reference/api Authenticate to the Nango HTTP API with your Nango secret key (docs): Authorization: Bearer ${NANGO_SECRET_KEY_DEV} . This is the Nango secret key (not a provider OAuth token). It typically lives in .env as NANGO_SECRET_KEY_DEV / NANGO_SECRET_KEY_PROD . Never print or paste secret keys into chat/logs; reference env vars in commands. Connections cheat sheet: --save after a successful --validate . If validation/save can't pass, stop and return early stating exactly what external state/inputs are missing (e.g., connection id, required metadata, required scopes/permissions, or a real sample response/resource id). Do not fabricate placeholder IDs or hashes (e.g., sample_hash_for_testing , mock-hash ) to make tests pass. If mocks are wrong/out-of-date, fix the code and re-record by re-running dryrun with --save . After --save , run nango generate:tests (required) to generate/update .test.ts . Error-path testing (do not encode errors in mocks) If you need to test error handling (404/401/429/timeouts), add/extend Vitest tests to mock nangoMock.get/post/patch/delete with vi.spyOn(...).mockRejectedValueOnce(...) or mockResolvedValueOnce(...) . Do not hand-edit
List connections
curl -sS "https://api.nango.dev/connections" \ -H "Authorization: Bearer ${NANGO_SECRET_KEY_DEV} "
Get a connection + credentials (auto-refreshes tokens)
curl
-sS
"https://api.nango.dev/connections/
Call a provider API through Nango Proxy (Nango injects provider auth)
curl
-sS
"https://api.nango.dev/proxy/
For no-input actions (input: z.object({}))
nango dryrun
Action (still requires --input)
nango dryrun
Sync
nango dryrun
=> { const response = await nango . get ( { // https://api-docs-url endpoint : '/api/v1/users' , params : { userId : input . user_id } , retries : 3 // safe for idempotent GETs; be careful retrying non-idempotent writes } ) ; if ( ! response . data ) { throw new nango . ActionError ( { type : 'not_found' , message : 'User not found' , user_id : input . user_id } ) ; } return { id : response . data . id , name : response . data . name ?? null } ; } } ) ; export type NangoActionLocal = Parameters < ( typeof action ) [ 'exec' ]
[ 0 ] ; export default action ; Action Metadata (When Required) Use metadata when the action depends on connection-specific values. const MetadataSchema = z . object ( { team_id : z . string ( ) } ) ; const action = createAction ( { metadata : MetadataSchema , exec : async ( nango , input ) => { const metadata = await nango . getMetadata < { team_id ? : string }
( ) ; const teamId = metadata ?. team_id ; if ( ! teamId ) { throw new nango . ActionError ( { type : 'invalid_metadata' , message : 'team_id is required in metadata.' } ) ; } } } ) ; Action CRUD Patterns Operation Method Config Pattern Create nango.post(config) data: { properties: {...} } Read nango.get(config) endpoint: resource/${id} , params: {...} Update nango.patch(config) endpoint: resource/${id} , data: {...} Delete nango.delete(config) endpoint: resource/${id} List nango.get(config) params: {...} with pagination Note: These endpoint examples are for ProxyConfiguration (provider API). The createAction endpoint path must stay static. Recommended in most configs: API doc link comment above endpoint retries: set intentionally (often 3 for idempotent GET/LIST; avoid retries for non-idempotent POST unless the API supports idempotency) Optional input fields pattern: data : { required_field : input . required_field , ... ( input . optional_field && { optional_field : input . optional_field } ) } Action Error Handling (ActionError) Use ActionError for expected failures (not found, validation, rate limit). Use standard Error for unexpected failures. if ( response . status === 429 ) { throw new nango . ActionError ( { type : 'rate_limited' , message : 'API rate limit exceeded' , retry_after : response . headers [ 'retry-after' ] } ) ; } Do not return null-filled objects to indicate "not found". Use ActionError instead. ActionError response format: { "error_type" : "action_script_failure" , "payload" : { "type" : "not_found" , "message" : "User not found" , "user_id" : "123" } } Action Pagination Standard (List Actions) All list actions must use cursor/next_cursor regardless of provider naming. Schema pattern: const ListInput = z . object ( { cursor : z . string ( ) . optional ( ) . describe ( 'Pagination cursor from previous response. Omit for first page.' ) } ) ; const ListOutput = z . object ( { items : z . array ( ItemSchema ) , next_cursor : z . union ( [ z . string ( ) , z . null ( ) ] ) } ) ; Provider mapping: Provider Native Input Native Output Map To Slack cursor response_metadata.next_cursor cursor -> next_cursor Notion start_cursor next_cursor cursor -> next_cursor HubSpot after paging.next.after cursor -> next_cursor GitHub page Link header cursor -> next_cursor Google pageToken nextPageToken cursor -> next_cursor Example: exec : async ( nango , input ) : Promise < z . infer < typeof ListOutput
=> { const config : ProxyConfiguration = { endpoint : 'api/items' , params : { ... ( input . cursor && { cursor : input . cursor } ) } , retries : 3 } ; const response = await nango . get ( config ) ; return { items : response . data . items . map ( ( item : { id : string ; name : string } ) => ( { id : item . id , name : item . name } ) ) , next_cursor : response . data . next_cursor || null } ; } Sync Template (createSync) import { createSync } from 'nango' ; import { z } from 'zod' ; const RecordSchema = z . object ( { id : z . string ( ) , name : z . union ( [ z . string ( ) , z . null ( ) ] ) } ) ; const sync = createSync ( { description : 'Brief single sentence' , version : '1.0.0' , endpoints : [ { method : 'GET' , path : '/provider/records' , group : 'Records' } ] , frequency : 'every hour' , autoStart : true , syncType : 'full' , models : { Record : RecordSchema } , exec : async ( nango ) => { // Sync logic here } } ) ; export type NangoSyncLocal = Parameters < ( typeof sync ) [ 'exec' ]
[ 0 ] ; export default sync ; Sync Deletion Detection Do not use trackDeletes: true . It is deprecated. Full refresh syncs (including checkpoint-based full refresh): call trackDeletesStart before fetching, and trackDeletesEnd after all batching record calls ( batchSave / batchUpdate / batchDelete ). Incremental syncs: if the API supports it, detect deletions and call batchDelete . Important: deletion detection is a soft delete. Records remain in the cache but are marked as deleted in metadata. Safety: only call trackDeletesEnd when the run successfully fetched + saved the full dataset between trackDeletesStart and trackDeletesEnd . Do not catch and swallow errors and still call it (false deletions). Reference: https://nango.dev/docs/implementation-guides/use-cases/syncs/deletion-detection await nango . trackDeletesStart ( 'Record' ) ; // ... fetch + batchSave all records ... await nango . trackDeletesEnd ( 'Record' ) ; Full Sync (Recommended) exec : async ( nango ) => { await nango . trackDeletesStart ( 'Record' ) ; const proxyConfig = { // https://api-docs-url endpoint : 'api/v1/records' , paginate : { limit : 100 } , retries : 3 } ; for await ( const batch of nango . paginate ( proxyConfig ) ) { const records = batch . map ( ( r : { id : string ; name : string } ) => ( { id : r . id , name : r . name ?? null } ) ) ; if ( records . length
0 ) { await nango . batchSave ( records , 'Record' ) ; } } await nango . trackDeletesEnd ( 'Record' ) ; } Incremental Sync const sync = createSync ( { syncType : 'incremental' , frequency : 'every 5 minutes' , exec : async ( nango ) => { const lastSync = nango . lastSyncDate ; const proxyConfig = { // https://api-docs-url endpoint : '/api/records' , params : { sort : 'updated' , ... ( lastSync && { since : lastSync . toISOString ( ) } ) } , paginate : { limit : 100 } , retries : 3 } ; for await ( const batch of nango . paginate ( proxyConfig ) ) { const records = batch . map ( ( record : { id : string ; name ? : string } ) => ( { id : record . id , name : record . name ?? null } ) ) ; await nango . batchSave ( records , 'Record' ) ; } if ( lastSync ) { const deleted = await nango . get ( { // https://api-docs-url endpoint : '/api/records/deleted' , params : { since : lastSync . toISOString ( ) } , retries : 3 } ) ; if ( deleted . data . length
0 ) { await nango . batchDelete ( deleted . data . map ( ( d : { id : string } ) => ( { id : d . id } ) ) , 'Record' ) ; } } } } ) ; Sync Metadata (When Required) const MetadataSchema = z . object ( { team_id : z . string ( ) } ) ; const sync = createSync ( { metadata : MetadataSchema , exec : async ( nango ) => { const metadata = await nango . getMetadata ( ) ; const teamId = metadata ?. team_id ; if ( ! teamId ) { throw new Error ( 'team_id is required in metadata.' ) ; } const response = await nango . get ( { // https://api-docs-url endpoint :
/v1/teams/ ${ teamId } /projects, retries : 3 } ) ; } } ) ; Note: nango.getMetadata() is cached for up to 60 seconds during a sync execution. Metadata updates may not be visible until the next run. Realtime Syncs (Webhooks) Use webhookSubscriptions + onWebhook when the provider supports webhooks. const sync = createSync ( { webhookSubscriptions : [ 'contact.propertyChange' ] , exec : async ( nango ) => { // Optional periodic polling } , onWebhook : async ( nango , payload ) => { if ( payload . subscriptionType === 'contact.propertyChange' ) { const updated = { id : payload . objectId , [ payload . propertyName ] : payload . propertyValue } ; await nango . batchSave ( [ updated ] , 'Contact' ) ; } } } ) ; Optional merge strategy: await nango . setMergingStrategy ( { strategy : 'ignore_if_modified_after' } , 'Contact' ) ; Key SDK Methods (Sync) Method Purpose nango.paginate(config) Iterate through paginated responses nango.batchSave(records, model) Save records to cache nango.batchDelete(records, model) Mark as deleted (incremental) nango.trackDeletesStart(model) Start automated deletion detection (full refresh) nango.trackDeletesEnd(model) Mark missing records as deleted (full refresh) nango.lastSyncDate Last sync timestamp (incremental) Pagination Helper (Advanced Config) Nango preconfigures pagination for some APIs. Override when needed. Pagination types: cursor, link, offset. const proxyConfig = { endpoint : '/tickets' , paginate : { type : 'cursor' , cursor_path_in_response : 'next' , cursor_name_in_request : 'cursor' , response_path : 'tickets' , limit_name_in_request : 'limit' , limit : 100 } , retries : 3 } ; for await ( const page of nango . paginate ( proxyConfig ) ) { await nango . batchSave ( page , 'Ticket' ) ; } Link pagination uses link_rel_in_response_header or link_path_in_response_body. Offset pagination uses offset_name_in_request. Manual Cursor-Based Pagination (If Needed) let cursor : string | undefined ; while ( true ) { const res = await nango . get ( { endpoint : '/api' , params : { cursor } , retries : 3 } ) ; const records = res . data . items . map ( ( item : { id : string ; name ? : string } ) => ( { id : item . id , name : item . name ?? null } ) ) ; await nango . batchSave ( records , 'Record' ) ; cursor = res . data . next_cursor ; if ( ! cursor ) break ; } Deploy (Optional) Deploy functions to an environment in your Nango account: nango deploy dev
Deploy only one function
nango deploy --action