diff --git a/sample-apps/sample-apps-with-iparams/crm_lead_on_customer_created_sample_app/handler/crmLead.js b/sample-apps/sample-apps-with-iparams/crm_lead_on_customer_created_sample_app/handler/crmLead.js index 58cc8a4..d19cd6e 100644 --- a/sample-apps/sample-apps-with-iparams/crm_lead_on_customer_created_sample_app/handler/crmLead.js +++ b/sample-apps/sample-apps-with-iparams/crm_lead_on_customer_created_sample_app/handler/crmLead.js @@ -30,7 +30,11 @@ async function postLeadToCrm(payload) { }; const res = await fetch(crmIntegration.crm_webhook_url.trim(), { method: 'POST', headers, body: JSON.stringify(body) }); const text = await res.text(); - if (!res.ok) throw new Error(`HubSpot contact create failed ${res.status}: ${text.slice(0, 500)}`); + if (!res.ok) { + const err = new Error(`HubSpot contact create failed ${res.status}: ${text.slice(0, 500)}`); + err.statusCode = res.status; + throw err; + } console.log('[crm-lead-sample] HubSpot contact create success; status=', res.status); } diff --git a/sample-apps/sample-apps-with-iparams/crm_lead_on_customer_created_sample_app/handler/handler.js b/sample-apps/sample-apps-with-iparams/crm_lead_on_customer_created_sample_app/handler/handler.js index 598289f..42f90c5 100644 --- a/sample-apps/sample-apps-with-iparams/crm_lead_on_customer_created_sample_app/handler/handler.js +++ b/sample-apps/sample-apps-with-iparams/crm_lead_on_customer_created_sample_app/handler/handler.js @@ -11,13 +11,22 @@ module.exports = { /** * Handles customer_created events * @param {import('../types/types').HandlerPayload} payload - Event and iparams + * @returns {Promise} */ customerCreatedHandler: async function (payload) { try { await postLeadToCrm(payload); console.log('Lead posted to CRM successfully'); } catch (err) { - throw new Error(err.message); + // 409: contact already exists in HubSpot — no value in retrying. + if (err.statusCode === 409) { + return { + statusCode: 409, + body: JSON.stringify({ message: 'Contact already exists in HubSpot; skipping duplicate lead creation' }), + }; + } + // Throw to signal a transient failure — the platform WILL retry (treated as 5xx). + throw err; } }, }; diff --git a/sample-apps/sample-apps-with-iparams/crm_lead_on_customer_created_sample_app/types/types.d.ts b/sample-apps/sample-apps-with-iparams/crm_lead_on_customer_created_sample_app/types/types.d.ts index 9e91d56..8f5b17f 100644 --- a/sample-apps/sample-apps-with-iparams/crm_lead_on_customer_created_sample_app/types/types.d.ts +++ b/sample-apps/sample-apps-with-iparams/crm_lead_on_customer_created_sample_app/types/types.d.ts @@ -61,3 +61,23 @@ export interface HandlerPayload { /** Installation parameter values grouped by section */ iparams: IparamInputs; } + +/** + * Optional return value from a handler function. + * + * Return this when the handler wants to signal an application-level error + * WITHOUT triggering a platform retry (e.g. invalid input, business rule + * violation). Throwing an exception causes the platform to retry the event; + * returning a HandlerResult with statusCode >= 400 does not. + * + * If the handler returns nothing (or undefined), statusCode defaults to 200. + */ +export interface HandlerResult { + /** + * HTTP status code (100–599). Defaults to 200 if omitted. + * Return 4xx to signal a non-retryable application error. + */ + statusCode?: number; + /** Optional response body (e.g. a JSON-encoded error message). */ + body?: string; +} diff --git a/sample-apps/sample-apps-with-iparams/schedule_advance_invoice_sample_app/handler/advanceInvoiceSchedule.js b/sample-apps/sample-apps-with-iparams/schedule_advance_invoice_sample_app/handler/advanceInvoiceSchedule.js index f82160f..a2a34e1 100644 --- a/sample-apps/sample-apps-with-iparams/schedule_advance_invoice_sample_app/handler/advanceInvoiceSchedule.js +++ b/sample-apps/sample-apps-with-iparams/schedule_advance_invoice_sample_app/handler/advanceInvoiceSchedule.js @@ -52,7 +52,7 @@ async function scheduleAdvanceInvoice(payload, site, apiKey) { message: err.message, subscription_id: eventContent.subscription.id, }); - return; + return { statusCode: status, body: JSON.stringify({ message: err.message, api_error_code: err.api_error_code }) }; } throw err; } diff --git a/sample-apps/sample-apps-with-iparams/schedule_advance_invoice_sample_app/handler/handler.js b/sample-apps/sample-apps-with-iparams/schedule_advance_invoice_sample_app/handler/handler.js index 857da76..42d85f8 100644 --- a/sample-apps/sample-apps-with-iparams/schedule_advance_invoice_sample_app/handler/handler.js +++ b/sample-apps/sample-apps-with-iparams/schedule_advance_invoice_sample_app/handler/handler.js @@ -11,15 +11,25 @@ module.exports = { /** * On `subscription_created`, configures an advance invoice schedule from iparams. * @param {import('../types/types.d.ts').HandlerPayload} payload - Event and iparams + * @returns {Promise} */ subscriptionCreatedHandler: async function (payload) { console.log('Processing subscription_created event'); + const scheduleType = payload.iparams?.advance_invoice_configuration?.schedule_type; + // Return 4xx to signal a non-retryable error — the platform will NOT retry. + if (scheduleType !== 'fixed' && scheduleType !== 'specific') { + return { + statusCode: 400, + body: JSON.stringify({ message: `Unsupported schedule_type: "${scheduleType}". Expected "fixed" or "specific".` }), + }; + } try { const site = process.env['CB_APPS_SITE_DOMAIN']; const apiKey = process.env['CB_APPS_READ_WRITE_API']; - await scheduleAdvanceInvoice(payload, site, apiKey); + return await scheduleAdvanceInvoice(payload, site, apiKey); } catch (error) { - throw new Error(error.message); + // Throw to signal a transient failure — the platform WILL retry (treated as 5xx). + throw error; } }, }; diff --git a/sample-apps/sample-apps-with-iparams/schedule_advance_invoice_sample_app/types/types.d.ts b/sample-apps/sample-apps-with-iparams/schedule_advance_invoice_sample_app/types/types.d.ts index 122ab77..f3e8022 100644 --- a/sample-apps/sample-apps-with-iparams/schedule_advance_invoice_sample_app/types/types.d.ts +++ b/sample-apps/sample-apps-with-iparams/schedule_advance_invoice_sample_app/types/types.d.ts @@ -61,3 +61,23 @@ export interface HandlerPayload { /** Installation parameter values grouped by section */ iparams: IparamInputs; } + +/** + * Optional return value from a handler function. + * + * Return this when the handler wants to signal an application-level error + * WITHOUT triggering a platform retry (e.g. invalid input, business rule + * violation). Throwing an exception causes the platform to retry the event; + * returning a HandlerResult with statusCode >= 400 does not. + * + * If the handler returns nothing (or undefined), statusCode defaults to 200. + */ +export interface HandlerResult { + /** + * HTTP status code (100–599). Defaults to 200 if omitted. + * Return 4xx to signal a non-retryable application error. + */ + statusCode?: number; + /** Optional response body (e.g. a JSON-encoded error message). */ + body?: string; +} diff --git a/sample-apps/sample-apps-without-iparams/dunning_cycle_end_handler_sample_app/handler/handler.js b/sample-apps/sample-apps-without-iparams/dunning_cycle_end_handler_sample_app/handler/handler.js index 0068612..cb2c285 100644 --- a/sample-apps/sample-apps-without-iparams/dunning_cycle_end_handler_sample_app/handler/handler.js +++ b/sample-apps/sample-apps-without-iparams/dunning_cycle_end_handler_sample_app/handler/handler.js @@ -10,15 +10,33 @@ module.exports = { /** * Must match `manifest.json` → `events.invoice_updated.handler`. * @param {import('../types/types').HandlerPayload} payload + * @returns {Promise} */ invoiceUpdatedHandler: async function (payload) { + const eventContent = payload.event.content; + const dunningStatus = eventContent?.invoice?.dunning_status || eventContent?.transaction?.dunning_status; + // Non-exhausted events are not actionable — ack silently without retrying. + if (dunningStatus !== 'exhausted') { + return; + } + // Mirrors the fallback in handleInvoiceUpdated: invoice.subscription_id, then subscription.id. + const subscriptionId = eventContent?.invoice?.subscription_id || eventContent?.subscription?.id; + // One-time invoices are not linked to a subscription — nothing to pause or cancel. + // Return 4xx to signal a non-retryable skip — the platform will NOT retry. + if (!subscriptionId) { + return { + statusCode: 400, + body: JSON.stringify({ message: 'Exhausted dunning invoice has no linked subscription; skipping dunning action' }), + }; + } try { const site = String(process.env['CB_APPS_SITE_DOMAIN'] || '').trim(); const apiKey = process.env['CB_APPS_READ_WRITE_API']; await handleInvoiceUpdated(payload, site, apiKey); console.log('Dunning exhaustion handled successfully'); } catch (error) { - throw new Error(error.message); + // Throw to signal a transient failure — the platform WILL retry (treated as 5xx). + throw error; } }, }; diff --git a/sample-apps/sample-apps-without-iparams/dunning_cycle_end_handler_sample_app/types/types.d.ts b/sample-apps/sample-apps-without-iparams/dunning_cycle_end_handler_sample_app/types/types.d.ts index 634b497..b199ae7 100644 --- a/sample-apps/sample-apps-without-iparams/dunning_cycle_end_handler_sample_app/types/types.d.ts +++ b/sample-apps/sample-apps-without-iparams/dunning_cycle_end_handler_sample_app/types/types.d.ts @@ -42,4 +42,24 @@ export interface EventRecord { */ export interface HandlerPayload { event: EventRecord; +} + +/** + * Optional return value from a handler function. + * + * Return this when the handler wants to signal an application-level error + * WITHOUT triggering a platform retry (e.g. invalid input, business rule + * violation). Throwing an exception causes the platform to retry the event; + * returning a HandlerResult with statusCode >= 400 does not. + * + * If the handler returns nothing (or undefined), statusCode defaults to 200. + */ +export interface HandlerResult { + /** + * HTTP status code (100–599). Defaults to 200 if omitted. + * Return 4xx to signal a non-retryable application error. + */ + statusCode?: number; + /** Optional response body (e.g. a JSON-encoded error message). */ + body?: string; } \ No newline at end of file