How to Build a Self-Service Refund Webhook with a Stripe Lookup

Build a synchronous Spojit webhook that accepts a refund request, looks up the Stripe customer and the charge to refund, issues the refund, and returns a confirmation in the same HTTP response.

What This Integration Does

When a support tool, an internal admin page, or a customer portal needs to refund a payment, you do not want a human logging into the Stripe dashboard for every request. This tutorial wires a Spojit workflow to a single endpoint that a caller can POST to: send a customer email and a charge identifier, and the workflow verifies the customer exists, confirms the charge belongs to them, issues the refund through Stripe, and replies with the refund amount and status. The caller gets a clean, structured answer in one round trip, so the requesting system can show the user a result immediately instead of polling.

The workflow runs on a Webhook trigger, which means it is synchronous: the caller holds the connection open while the workflow runs and reads the body that your Response node returns. Each request flows from the parsed JSON body, through two read-only Stripe lookups and a write that issues the refund, into a single confirmation object. The workflow leaves Stripe state changed (a refund is created and the charge is marked refunded) but stores nothing of its own beyond the execution record. Because refunds are not safely repeatable, the workflow checks the charge's existing refund state before issuing, so a replayed request does not double-refund.

Prerequisites

  • A Stripe connection added under Connections with an API key that can read customers and charges and create refunds. See the Stripe connector article for setup.
  • A signing connection for the Webhook trigger (scheme Spojit or Custom) so incoming requests are verified by HMAC. See setting up a webhook connection.
  • The calling system must be able to send the customer's email and the Stripe charge ID (for example ch_3Pabcd...) it wants refunded.
  • A clear refund policy on the caller's side: this workflow issues a full refund of the charge unless you pass a partial amount.

Step 1: Add the Webhook trigger and define the request shape

On a new workflow canvas, set the Trigger node type to Webhook. Choose your signing connection so Spojit verifies each request by HMAC and rejects unsigned callers. Copy the generated workflow URL: this is the endpoint the caller POSTs to. The trigger output is the parsed JSON body, available downstream as {{ input }}. Decide on a request contract and document it for the caller. A minimal body looks like this:

{
  "customerEmail": "jordan@example.com",
  "chargeId": "ch_3PabcdEfGhIjKlMn1234",
  "amount": null,
  "reason": "requested_by_customer"
}

Here amount is optional: leave it null for a full refund, or pass a value in the smallest currency unit (cents for USD) for a partial refund. The reason maps to a Stripe refund reason such as requested_by_customer, duplicate, or fraudulent.

Step 2: Look up the Stripe customer

Add a Connector node in Direct mode and select the Stripe connector with the list-customers tool. Map its email field to {{ input.customerEmail }} and set limit to 1. This confirms the requester corresponds to a real customer before you touch any payment. Name the result so you can reference it, for example customerLookup. If you prefer to resolve a known Stripe customer ID directly, use get-customer with an id instead, but for an email-driven self-service flow list-customers is the natural entry point.

Step 3: Fetch the charge and confirm ownership

Add a second Connector node in Direct mode using the Stripe connector with the list-charges tool. Map its customer field to the customer ID returned by the previous lookup (for example {{ customerLookup.data[0].id }}) so you only ever see charges that belong to that customer. Name the result chargeLookup. This step matters: filtering charges by the resolved customer ensures a caller cannot pass someone else's chargeId and refund a charge they do not own. You will compare the returned charge IDs against {{ input.chargeId }} in the next step.

Step 4: Guard against missing charges and replays

Add a Condition node to branch on whether the requested charge is valid and still refundable. Configure the true branch to require that the charge in {{ chargeLookup }} matching {{ input.chargeId }} exists and that its refunded flag is not already true (and that amount_refunded has room left for the requested amount). If you want help expressing the match, ask Miraxa, the intelligent layer across your automation, to "add a Condition that passes only when a charge in {{ chargeLookup }} has id equal to {{ input.chargeId }} and refunded is false."

On the false branch, add a Response node that returns a clear rejection so the caller knows why nothing was refunded:

{
  "status": "rejected",
  "reason": "Charge not found for this customer, or already refunded"
}

Step 5: Issue the refund through Stripe

On the true branch, add a Connector node in Direct mode using the Stripe connector with the raw-api-request tool, because refunds are created against the dedicated refunds resource. Set method to POST, path to /refunds, and map body to the refund parameters. Stripe form-encodes the body for you. Reference the matched charge ID and pass the optional amount and reason from the request:

{
  "charge": "{{ input.chargeId }}",
  "amount": "{{ input.amount }}",
  "reason": "{{ input.reason }}"
}

Omit amount entirely for a full refund: only include it when the caller asked for a partial refund. Name the result refund so the next step can read its id, status, and amount.

Step 6: Return a confirmation to the caller

Add a Response node at the end of the true branch to send the result back on the open HTTP connection. Build a compact confirmation object from the refund result so the calling system can display it directly:

{
  "status": "refunded",
  "refundId": "{{ refund.data.id }}",
  "chargeId": "{{ input.chargeId }}",
  "amountRefunded": "{{ refund.data.amount }}",
  "currency": "{{ refund.data.currency }}",
  "stripeStatus": "{{ refund.data.status }}"
}

Save and enable the workflow. The caller now POSTs a request to the webhook URL and receives this confirmation in the response body once the refund completes. For a deeper look at returning synchronous results, see using Response nodes.

Tips

  • Stripe amounts are in the smallest currency unit. A 25.00 USD refund is 2500. Validate amount on the caller's side so you never pass a value larger than the charge total.
  • If you want a human to sign off on high-value refunds, insert a Human approval node before Step 5 and gate it on the amount. The webhook will hold the response until the approval resolves, so keep the timeout short for synchronous callers, or switch those cases to an async pattern.
  • To notify your team on every refund, add a Send Email node or a Slack connector send-message call after Step 5 using {{ refund.data.id }} and {{ input.customerEmail }}.
  • Use list-charges rather than refunding a raw caller-supplied charge ID directly: the customer-scoped lookup is what prevents cross-customer refunds.

Common Pitfalls

  • Double refunds on replays. If the caller retries a timed-out request, the same charge could be refunded twice. The Step 4 check on the refunded flag and amount_refunded is what makes the workflow safe to re-run. You can also enable event-id dedup on the Webhook trigger so identical requests collapse.
  • Skipping the ownership check. Refunding {{ input.chargeId }} without first confirming it appears in the customer's charge list lets a caller refund a charge they do not own. Always filter list-charges by the resolved customer.
  • Empty customer lookup. If list-charges or the customer lookup returns no rows, references like {{ customerLookup.data[0].id }} resolve empty and the refund fails. Handle the no-match case in the Condition node and return a rejection.
  • API key scope. A read-only Stripe key can complete Steps 2 and 3 but will fail at Step 5. Confirm the connection's key can create refunds before going live.

Testing

Create a test charge in Stripe test mode and copy its ch_... ID and the customer's email. With the workflow saved but used against test-mode keys, POST a request body containing those values to the webhook URL and watch the execution in the run history. Confirm each step: the customer resolves, the charge appears in the customer-scoped list, the Condition passes, the refund is created, and the Response body reports "status": "refunded" with the expected amount. Then POST the same body again and confirm the second run takes the rejection branch because the charge is already refunded. Once both paths behave, point a small slice of real traffic at the endpoint before opening it to all callers.

Learn More

Did this answer your question? Thanks for the feedback There was a problem submitting your feedback. Please try again later.