{ "name": "Stripe test refund with Allowly approval", "nodes": [ { "id": "ede3b6aa-8095-5f1d-ab86-22df8463608d", "name": "Run test refund", "type": "n8n-nodes-base.manualTrigger", "typeVersion": 1, "position": [ 0, 0 ], "parameters": {} }, { "id": "5f9c1054-906e-5aea-88ab-7b1595f72e8e", "name": "Refund request", "type": "n8n-nodes-base.set", "typeVersion": 3.4, "position": [ 220, 0 ], "parameters": { "mode": "raw", "jsonOutput": "{\n \"authorizationId\": \"REPLACE_WITH_STORED_AUTHORIZATION_ID\",\n \"refundRequestId\": \"demo-refund-001\",\n \"paymentIntentId\": \"REPLACE_WITH_TEST_PAYMENT_INTENT_ID\",\n \"amountMinor\": 2500,\n \"currency\": \"usd\",\n \"reason\": \"requested_by_customer\"\n}", "options": {} } }, { "id": "ad7620a8-fa5b-5bc1-b2cb-e4bac5ee4ad0", "name": "Validate request", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 440, 0 ], "parameters": { "jsCode": "if ($input.all().length !== 1) throw new Error('Run one refund request at a time.');\nconst r = $input.first().json;\nif (typeof r.authorizationId !== 'string' || !/^auth_[A-Za-z0-9_-]+$/.test(r.authorizationId)) throw new Error('Set a stored Allowly authorization ID.');\nif (typeof r.refundRequestId !== 'string' || !/^[A-Za-z0-9_-]{1,64}$/.test(r.refundRequestId)) throw new Error('Use a stable refund request ID of 1-64 ASCII letters, digits, hyphens or underscores.');\nif (typeof r.paymentIntentId !== 'string' || !/^pi_[A-Za-z0-9]{1,80}$/.test(r.paymentIntentId)) throw new Error('Set a Stripe test PaymentIntent ID.');\nif (!Number.isSafeInteger(r.amountMinor) || r.amountMinor < 1 || r.amountMinor > 99999999) throw new Error('amountMinor must be a positive integer, at most 99999999.');\nif (r.currency !== 'usd' || r.reason !== 'requested_by_customer') throw new Error('This example supports USD customer-requested refunds only.');\n// Bind every executed refund field to approval; no crypto module or extra dependency.\nconst resource = ['stripe-refund', r.refundRequestId, r.paymentIntentId, r.amountMinor, r.currency, r.reason].join(':');\nreturn [{json: {authorizationId:r.authorizationId, refundRequestId:r.refundRequestId, paymentIntentId:r.paymentIntentId, amountMinor:r.amountMinor, currency:r.currency, reason:r.reason, resource, stripeIdempotencyKey:'allowly-refund:' + r.refundRequestId, context:{refund_request_id:r.refundRequestId, payment_intent_id:r.paymentIntentId, amount_minor:r.amountMinor, currency:r.currency, reason:r.reason}}}];" } }, { "id": "acd4580d-1715-5e15-86c1-4e360619598f", "name": "Read Stripe payment", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 660, 0 ], "parameters": { "url": "={{ 'https://api.stripe.com/v1/payment_intents/' + $('Validate request').first().json.paymentIntentId }}", "authentication": "predefinedCredentialType", "nodeCredentialType": "stripeApi", "options": { "timeout": 15000, "redirect": { "redirect": { "followRedirects": false } } } } }, { "id": "e4b7ffa2-805b-5560-93f1-a47605fcda90", "name": "Require test payment", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 880, 0 ], "parameters": { "jsCode": "const p = $input.first().json;\nconst r = $('Validate request').first().json;\nif (p.object !== 'payment_intent' || p.id !== r.paymentIntentId || p.livemode !== false) throw new Error('A matching Stripe test-mode PaymentIntent is required; live or malformed responses stop here.');\nif (p.status !== 'succeeded' || p.currency !== r.currency || !Number.isSafeInteger(p.amount_received) || r.amountMinor > p.amount_received) throw new Error('Use a succeeded USD test payment with enough received funds.');\nreturn [{json:r}];" } }, { "id": "5421defa-a546-5d18-8b36-03886f2c124b", "name": "Check refund", "type": "n8n-nodes-allowly.allowly", "typeVersion": 1, "position": [ 1100, 0 ], "parameters": { "operation": "check", "authorization": "={{ $('Validate request').first().json.authorizationId }}", "actions": "refund.create", "resource": "={{ $('Validate request').first().json.resource }}", "session": "={{ $('Validate request').first().json.refundRequestId }}", "estimatedCostMicros": -1, "workflowUser": "", "workflowAgent": "", "contextJson": "={{ $('Validate request').first().json.context }}" } }, { "id": "53cb019c-8c6e-5375-be7d-31e998831c36", "name": "Validate decision", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1320, 0 ], "parameters": { "jsCode": "const d = $input.first().json;\nconst result = d.results?.['refund.create'];\nif (d.action !== 'refund.create' || !['allow','deny','confirm','escalate'].includes(d.decision) || result?.decision !== d.decision) throw new Error('Invalid Allowly decision; no refund.');\nconst receiptId = d.receipt?.receipt_id ?? d.receipt?.receipt?.receipt_id;\nif (typeof receiptId !== 'string' || !/^rcp_[A-Za-z0-9_-]+$/.test(receiptId)) throw new Error('Missing decision receipt; no refund.');\nif (d.decision === 'confirm' && (typeof d.confirmNonce !== 'string' || !d.confirmNonce)) throw new Error('Missing confirmation nonce.');\nif (d.decision === 'escalate' && (typeof d.escalationId !== 'string' || !d.escalationId)) throw new Error('Missing escalation ID.');\nreturn [{json:{...d, receiptId}}];" } }, { "id": "f766392a-7a5b-5d76-8881-e944f5c49a92", "name": "Allowed immediately", "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [ 1540, 0 ], "parameters": { "conditions": { "options": { "caseSensitive": true, "typeValidation": "strict", "version": 2 }, "conditions": [ { "id": "Allowed immediately", "leftValue": "={{ $json.decision === 'allow' }}", "rightValue": true, "operator": { "type": "boolean", "operation": "true", "singleValue": true } } ], "combinator": "and" }, "options": {} } }, { "id": "88cb7d0e-5743-56b4-97d9-d00ee3595a3b", "name": "Needs review", "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [ 1760, 240 ], "parameters": { "conditions": { "options": { "caseSensitive": true, "typeValidation": "strict", "version": 2 }, "conditions": [ { "id": "Needs review", "leftValue": "={{ $json.decision === 'confirm' || $json.decision === 'escalate' }}", "rightValue": true, "operator": { "type": "boolean", "operation": "true", "singleValue": true } } ], "combinator": "and" }, "options": {} } }, { "id": "ea0b97a6-bb76-5f45-ae9b-3ffb775ea6cb", "name": "Stop refund", "type": "n8n-nodes-base.stopAndError", "typeVersion": 1, "position": [ 1980, 480 ], "parameters": { "errorType": "errorMessage", "errorMessage": "Refund stopped. Inspect the decision or review result; do not bypass the checks." } }, { "id": "5cdcaf97-4b1a-5c35-989e-37831fa4d472", "name": "Wait for reviewer", "type": "n8n-nodes-base.wait", "typeVersion": 1.1, "position": [ 1980, 200 ], "parameters": { "resume": "webhook", "incomingAuthentication": "headerAuth", "httpMethod": "POST", "responseMode": "onReceived", "responseCode": 200, "limitWaitTime": true, "limitType": "afterTimeInterval", "resumeAmount": 4, "resumeUnit": "minutes", "options": {} }, "webhookId": "3a0400db-3de8-5360-943e-d21d677fb81e" }, { "id": "dcc52c6b-3301-52b4-b0cb-9e13af19b232", "name": "Validate review", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 2200, 200 ], "parameters": { "jsCode": "const body = $input.first().json.body;\nconst request = $('Validate request').first().json;\nif (!body || typeof body !== 'object' || Array.isArray(body) || Object.keys(body).sort().join(',') !== 'approved,resource') throw new Error('Review missing, timed out, or has unexpected fields.');\nif (typeof body.approved !== 'boolean' || body.resource !== request.resource) throw new Error('Review must contain an explicit boolean and the exact frozen refund resource.');\nreturn [{json:{approved:body.approved}}];" } }, { "id": "d2196dab-1e54-5914-be27-47b9ffb8ac53", "name": "Confirmation requested", "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [ 2420, 200 ], "parameters": { "conditions": { "options": { "caseSensitive": true, "typeValidation": "strict", "version": 2 }, "conditions": [ { "id": "Confirmation requested", "leftValue": "={{ $('Validate decision').first().json.decision === 'confirm' }}", "rightValue": true, "operator": { "type": "boolean", "operation": "true", "singleValue": true } } ], "combinator": "and" }, "options": {} } }, { "id": "dba9b5ec-e426-55a7-be9e-c6d276c7ba62", "name": "Resolve confirmation", "type": "n8n-nodes-allowly.allowly", "typeVersion": 1, "position": [ 2640, 100 ], "parameters": { "operation": "resolveConfirmation", "confirmationNonce": "={{ $('Validate decision').first().json.confirmNonce }}", "confirmationApproved": "={{ $('Validate review').first().json.approved }}", "confirmationTtlSeconds": 60, "confirmationIdempotencyKey": "" } }, { "id": "bc94342a-b4c3-5058-9408-511f34448a90", "name": "Resolve escalation", "type": "n8n-nodes-allowly.allowly", "typeVersion": 1, "position": [ 2640, 300 ], "parameters": { "operation": "resolveEscalation", "escalationId": "={{ $('Validate decision').first().json.escalationId }}", "escalationResolution": "={{ $('Validate review').first().json.approved ? 'approved' : 'rejected' }}", "escalationResolvedBy": "n8n-refund-review-demo", "escalationNote": "Customer-reported outcome from the authenticated demo review endpoint." } }, { "id": "16e3fa22-6eb3-5ca3-ab24-0a7bfbe72bb3", "name": "Reviewer approved", "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [ 2860, 200 ], "parameters": { "conditions": { "options": { "caseSensitive": true, "typeValidation": "strict", "version": 2 }, "conditions": [ { "id": "Reviewer approved", "leftValue": "={{ $('Validate review').first().json.approved === true }}", "rightValue": true, "operator": { "type": "boolean", "operation": "true", "singleValue": true } } ], "combinator": "and" }, "options": {} } }, { "id": "8ec52c68-f301-508a-9a17-c362ad690a94", "name": "Recheck refund after review", "type": "n8n-nodes-allowly.allowly", "typeVersion": 1, "position": [ 3080, 200 ], "parameters": { "operation": "check", "authorization": "={{ $('Validate request').first().json.authorizationId }}", "actions": "refund.create", "resource": "={{ $('Validate request').first().json.resource }}", "session": "={{ $('Validate request').first().json.refundRequestId }}", "estimatedCostMicros": -1, "workflowUser": "", "workflowAgent": "", "contextJson": "={{ $('Validate request').first().json.context }}" } }, { "id": "a3f0508c-cd9d-5c81-a7bf-645182363d15", "name": "Require final allow", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 3300, 200 ], "parameters": { "jsCode": "const d = $input.first().json;\nconst receiptId = d.receipt?.receipt_id ?? d.receipt?.receipt?.receipt_id;\nif (d.action !== 'refund.create' || d.decision !== 'allow' || d.results?.['refund.create']?.decision !== 'allow' || typeof receiptId !== 'string' || !/^rcp_[A-Za-z0-9_-]+$/.test(receiptId)) throw new Error('Fresh check did not allow this refund; start a new review.');\nreturn [{json:{...d,receiptId}}];" } }, { "id": "6a84cbca-71c3-5f54-a87a-c10b92a1b175", "name": "Refund permitted", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 3520, -160 ], "parameters": { "jsCode": "const decision = $input.first().json;\nreturn [{json:{request:$('Validate request').first().json,decision}}];" } }, { "id": "691a8989-9644-5f23-a6dd-d0cfa093b98c", "name": "Create Stripe test refund", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 3740, -160 ], "parameters": { "url": "https://api.stripe.com/v1/refunds", "authentication": "predefinedCredentialType", "nodeCredentialType": "stripeApi", "options": { "timeout": 15000, "redirect": { "redirect": { "followRedirects": false } } }, "method": "POST", "sendHeaders": true, "headerParameters": { "parameters": [ { "name": "Idempotency-Key", "value": "={{ $('Validate request').first().json.stripeIdempotencyKey }}" } ] }, "sendBody": true, "contentType": "form-urlencoded", "bodyParameters": { "parameters": [ { "name": "payment_intent", "value": "={{ $('Validate request').first().json.paymentIntentId }}" }, { "name": "amount", "value": "={{ $('Validate request').first().json.amountMinor }}" }, { "name": "reason", "value": "={{ $('Validate request').first().json.reason }}" }, { "name": "metadata[allowly_refund_request_id]", "value": "={{ $('Validate request').first().json.refundRequestId }}" } ] } } }, { "id": "3ae0a59a-c89e-52dc-b2fe-40279da21c9a", "name": "Refund result", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 3960, -160 ], "parameters": { "jsCode": "const refund = $input.first().json;\nconst permitted = $('Refund permitted').first().json;\nconst r = permitted.request;\nif (refund.object !== 'refund' || typeof refund.id !== 'string' || !/^re_[A-Za-z0-9]+$/.test(refund.id) || refund.payment_intent !== r.paymentIntentId || refund.amount !== r.amountMinor || refund.currency !== r.currency || typeof refund.status !== 'string') throw new Error('Unexpected refund response. Reconcile in Stripe before retrying; keep the same request ID.');\nreturn [{json:{request:r,decision:permitted.decision,stripeRefund:{id:refund.id,payment_intent:refund.payment_intent,amount:refund.amount,currency:refund.currency,status:refund.status},receiptVerification:'not_performed'}}];" } }, { "id": "7bb2510c-7d98-55a5-9ccd-4d5a5049efd7", "name": "Fetch decision receipt", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 4180, -160 ], "parameters": { "url": "={{ 'https://api.allowly.ai/v1/receipts/' + $('Refund result').first().json.decision.receiptId }}", "authentication": "predefinedCredentialType", "nodeCredentialType": "allowlyApi", "options": { "timeout": 15000, "redirect": { "redirect": { "followRedirects": false } } } } }, { "id": "0d0eafe6-6006-5bea-a90e-9914b382eaa9", "name": "Export refund evidence", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 4400, -160 ], "parameters": { "jsCode": "const evidence = $('Refund result').first().json;\nconst envelope = $input.first().json;\nconst receiptId = envelope.receipt_id ?? envelope.receipt?.receipt_id;\nif (!['signed','pending'].includes(envelope.status) || receiptId !== evidence.decision.receiptId) throw new Error('Unexpected receipt response. Keep Refund result and retrieve its receipt ID separately; do not create another refund.');\nif (envelope.status === 'signed' && typeof envelope.receipt?.signature !== 'string') throw new Error('Signed receipt is missing its signature.');\nreturn [{json:{...evidence,decisionReceipt:envelope,receiptPending:envelope.status === 'pending'}}];" } }, { "id": "762338a8-63ae-52a9-b845-22574b046ae9", "name": "Read before running", "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [ 0, -460 ], "parameters": { "content": "## Stripe test refunds with Allowly 0.2.1\nRead examples/stripe-refund-with-approval.md first.\nSelect Allowly API credentials on all Allowly nodes and Fetch decision receipt. Select the SAME Stripe TEST credential on both Stripe HTTP nodes. Set Header Auth on Wait for reviewer.\nCreate/store authorization once. Configure Refund request. Only USD test payments run. Review body: {\"approved\":true,\"resource\":\"EXACT RESOURCE FROM Validate request\"}. Four-minute timeout stops.\nRefund result is available even if receipt retrieval fails. Pending receipts are not signed or verified. Never change the request ID to retry an uncertain refund.", "height": 340, "width": 700 } } ], "connections": { "Run test refund": { "main": [ [ { "node": "Refund request", "type": "main", "index": 0 } ] ] }, "Refund request": { "main": [ [ { "node": "Validate request", "type": "main", "index": 0 } ] ] }, "Validate request": { "main": [ [ { "node": "Read Stripe payment", "type": "main", "index": 0 } ] ] }, "Read Stripe payment": { "main": [ [ { "node": "Require test payment", "type": "main", "index": 0 } ] ] }, "Require test payment": { "main": [ [ { "node": "Check refund", "type": "main", "index": 0 } ] ] }, "Check refund": { "main": [ [ { "node": "Validate decision", "type": "main", "index": 0 } ] ] }, "Validate decision": { "main": [ [ { "node": "Allowed immediately", "type": "main", "index": 0 } ] ] }, "Allowed immediately": { "main": [ [ { "node": "Refund permitted", "type": "main", "index": 0 } ], [ { "node": "Needs review", "type": "main", "index": 0 } ] ] }, "Needs review": { "main": [ [ { "node": "Wait for reviewer", "type": "main", "index": 0 } ], [ { "node": "Stop refund", "type": "main", "index": 0 } ] ] }, "Wait for reviewer": { "main": [ [ { "node": "Validate review", "type": "main", "index": 0 } ] ] }, "Validate review": { "main": [ [ { "node": "Confirmation requested", "type": "main", "index": 0 } ] ] }, "Confirmation requested": { "main": [ [ { "node": "Resolve confirmation", "type": "main", "index": 0 } ], [ { "node": "Resolve escalation", "type": "main", "index": 0 } ] ] }, "Resolve confirmation": { "main": [ [ { "node": "Reviewer approved", "type": "main", "index": 0 } ] ] }, "Resolve escalation": { "main": [ [ { "node": "Reviewer approved", "type": "main", "index": 0 } ] ] }, "Reviewer approved": { "main": [ [ { "node": "Recheck refund after review", "type": "main", "index": 0 } ], [ { "node": "Stop refund", "type": "main", "index": 0 } ] ] }, "Recheck refund after review": { "main": [ [ { "node": "Require final allow", "type": "main", "index": 0 } ] ] }, "Require final allow": { "main": [ [ { "node": "Refund permitted", "type": "main", "index": 0 } ] ] }, "Refund permitted": { "main": [ [ { "node": "Create Stripe test refund", "type": "main", "index": 0 } ] ] }, "Create Stripe test refund": { "main": [ [ { "node": "Refund result", "type": "main", "index": 0 } ] ] }, "Refund result": { "main": [ [ { "node": "Fetch decision receipt", "type": "main", "index": 0 } ] ] }, "Fetch decision receipt": { "main": [ [ { "node": "Export refund evidence", "type": "main", "index": 0 } ] ] } }, "settings": { "executionOrder": "v1" }, "active": false, "pinData": {}, "tags": [] }