Case studies
CASE STUDY — Operations

Payment → tax invoice → AlimTalk with nobody in the loop — queueing the Toss Payments webhook

Deployed at B2B services / online retail (Korea)5 days to build
Toss Payments Webhooke-Tax invoice APIAlimTalkn8nPostgres

Payment was already automatic. Everything after it was not. Someone issued the tax invoice by hand, told the customer by hand, and reconciled the deposit by hand.

All three have APIs. Nobody had connected them. But chaining them off a single webhook will almost certainly cause an incident. Half of this case study is about why, and about the structure that avoids it.

payment-taxinvoice.workflowLIVE
WHPayment webhook200Respond + enqueueTIIssue invoiceATNotify customer

Why you can't just chain it

The Toss Payments webhook documentation states two constraints. These two sentences determine the entire design.

  • Response time — the docs say to return a 200 within 10 seconds. But calling a tax-invoice API and then a messaging API in sequence can exceed that. On a slow day for either vendor, it certainly will.
  • Retries — if no response arrives or it fails, Toss retries up to 7 times, at intervals of 1 → 4 → 16 → 64 → 256 → 1024 → 4096 minutes. In the documentation's own words, the final retry lands *"3 days and 19 hours after the first delivery."*
  • Put those together and the worst case is obvious. The invoice is issued successfully, the response takes more than 10 seconds and is treated as a failure, and Toss sends the same payment again. Another invoice is issued. Duplicate tax invoices are a real incident requiring cancellation and reissue paperwork.
So this workflow is split in two. The webhook flow validates, enqueues, and responds 200 immediately. A separate scheduled flow does the slow vendor work. The webhook never waits on a vendor API.

How it works

  1. A Webhook node receives Toss's PAYMENT_STATUS_CHANGED event. Toss emits this on every status transition, so only completed payments (`DONE`) are kept.
  2. A Respond to Webhook node returns 200 straight away. However long the rest takes, Toss already considers delivery successful. The 10-second limit stops being a constraint at all.
  3. A Code node normalizes the payload and takes paymentKey as the idempotency key. Anything that isn't DONE, or has no paymentKey, returns an empty array and ends quietly.
  4. A Postgres node inserts into invoice_queue with ON CONFLICT DO NOTHING. A redelivered payment adds no row. This is the exact point where duplicate issuance is prevented.
  5. A separate Schedule Trigger sweeps the queue every 5 minutes, claiming up to 20 pending rows and flipping them to `processing` in the same statement. Because it's one statement, overlapping runs can't claim the same row twice.
  6. It calls the tax-invoice API, then sends AlimTalk (or SMS), then marks the row done. On failure attempts increments, and past 5 the row is no longer claimed.

What you need

  • n8n — self-hosted or Cloud. The webhook must be reachable from the internet, so you need a publicly addressable URL. Developing locally, use a tunnelling tool or n8n Cloud.
  • One Postgres database — for a single queue table.
  • A Toss Payments account — register the webhook URL under the developer centre's webhook menu (developers.tosspayments.com/my/webhooks). Test keys begin with test, and per the documentation you can see *"some test keys for the integration demo store"* even before applying for payment processing — so development and testing don't require a business registration. Authentication is Basic auth with the secret key, and the trailing : must be included when encoding.
  • An e-tax-invoice service account — Popbill, Barobill, or similar. Integrating directly with the national tax service is not recommended (see edge cases). Popbill runs a separate test environment at test.popbill.com, and its documentation states that a partner is *"automatically registered in both the Popbill test and production environments using the account credentials entered at integration application."* Test certificate issuance is also offered. Whether the integration application itself requires business details is not confirmed in the official documentation — ask the vendor directly before committing.
  • For AlimTalk — a contract with an official dealer comes first — Kakao's own documentation states that AlimTalk *"can only be carried out through an official dealer holding a partner contract with Kakao."* You cannot integrate with Kakao directly; you must contract with one of its official dealers. That is also why this case doesn't pin the messaging node to a specific vendor — the API differs by dealer. There are channel prerequisites too: open a KakaoTalk channel, convert it to a business channel, set its home visibility to ON, and provide customer-service details. The message itself must be *"informational, falling under the exceptions to commercial advertising defined by the Network Act"* — advertising cannot be sent this way, though payment and issuance notices qualify. If you aren't set up yet, swap this step for SMS; the workflow structure is unchanged.

Step 1 — create the queue table

CREATE TABLE IF NOT EXISTS invoice_queue (
  payment_key    text PRIMARY KEY,          -- idempotency key; the duplicate defence
  order_id       text,
  amount         numeric     NOT NULL DEFAULT 0,
  approved_at    timestamptz,
  customer_email text,
  status         text        NOT NULL DEFAULT 'pending',  -- pending|processing|done
  attempts       integer     NOT NULL DEFAULT 0,          -- stops infinite retries
  created_at     timestamptz NOT NULL DEFAULT now(),
  updated_at     timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS invoice_queue_status_idx
  ON invoice_queue (status, created_at);
Making payment_key the PRIMARY KEY is the single most important line here. A database constraint prevents duplicates, not application code. However buggy the workflow, however much runs overlap, however many times Toss redelivers — there is one row.

Step 2 — the enqueue query

INSERT INTO invoice_queue
  (payment_key, order_id, amount, approved_at, customer_email, status)
VALUES
  ('{{ $json.payment_key }}', '{{ $json.order_id }}', {{ $json.amount }},
   '{{ $json.approved_at }}', '{{ $json.customer_email }}', 'pending')
ON CONFLICT (payment_key) DO NOTHING;

When a redelivered webhook arrives, this returns INSERT 0 0 and nothing happens. That isn't an error — it's a correct, deliberate ignore. The workflow finishes quietly as a success.

Step 3 — the claim query

This is what the five-minute processor runs. A single UPDATE ... RETURNING claims rows and changes their state at the same time. Split into two statements, another run could claim the same rows in between.

UPDATE invoice_queue
SET status = 'processing', attempts = attempts + 1, updated_at = now()
WHERE payment_key IN (
  SELECT payment_key FROM invoice_queue
  WHERE status = 'pending' AND attempts < 5
  ORDER BY created_at
  LIMIT 20
  FOR UPDATE SKIP LOCKED       -- step over rows another run has locked
)
RETURNING payment_key, order_id, amount, approved_at, customer_email, attempts;
FOR UPDATE SKIP LOCKED is the concurrency guard. If processing runs longer than five minutes and the next execution overlaps, the second run skips locked rows rather than waiting on them. attempts < 5 keeps a permanently failing row from blocking the queue forever.

Step 4 — filling in the two vendor calls

In the downloadable workflow, Issue Tax Invoice and Send AlimTalk / SMS are deliberately left blank. Request formats differ per vendor, and transcribing one vendor's schema here would make this document wrong the moment that vendor changes its API. Fill in URL, auth headers, and body from your own provider's reference. Everything else in the workflow stands as-is.

  • The invoice node has a 20-second timeout. Because of the queue, slowness here never touches the webhook.
  • The AlimTalk node needs an approved template ID. No sender profile yet? Point it at SMS instead — the surrounding structure is identical.
  • The order of these two matters — always notify after issuance succeeds. A "your invoice has been issued" message following a failed issuance is a worse incident than the failure.

What to know before designing an AlimTalk template

This is where most of the time gets lost. These constraints are stated in the official guides.

  • A completed template cannot be edited. Changing one word means creating a new template and going through review again. So build in spare variables from the start. You will want to add an order number after shipping with only the amount.
  • Review is processed within 2 business days. The guide states review requests are *"processed in order within 2 business days."* A rejection means fixing and resubmitting, so budget schedule for a round trip.
  • Documented rejection reasons — variable errors, excessive variables (more than 40), templates whose content consists only of variables, variables inside button labels, and variables in the preview message. The variables-only case is the common mistake: a template with no fixed copy will be rejected.
  • Fallback delivery settings are managed separately and can be changed at any time. The template is frozen; the SMS-fallback configuration is not.
⬇︎ Download the workflow (payment-taxinvoice.json)
With one Postgres credential and the queue table, the receive/enqueue/process structure runs as-is. Fill the two vendor nodes from your own provider's reference.
Being precise about what was verified. The Toss Payments figures quoted here — the 10-second response limit, the retry policy (up to 7 attempts at 1/4/16/64/256/1024/4096-minute intervals, final retry 3 days 19 hours after first delivery), the 10 event types, and test keys beginning with test — along with the AlimTalk rules (approved templates only, completed templates cannot be edited, review within 2 business days, and the rejection-reason list) were read directly from the official documentation on 2026-08-15. Queue behaviour (a duplicate payment_key failing to insert, rows past the attempt limit being skipped, and overlapping runs never reprocessing the same row) was executed against fixtures on PostgreSQL 17. Not verified: whether Popbill's integration application requires business details, and whether creating an AlimTalk sender profile requires a business registration — no official source was found for either, so neither is asserted above. No end-to-end test across a real payment, issuance, and delivery was performed. Last verified: 2026-08-15.

Setup (40 minutes)

  1. Create the queue table — run the Step 1 SQL.
  2. Register the Postgres credential — n8n → Credentials → PostgresTest for a green check. Docker n8n with a host database needs host.docker.internal as the host.
  3. Import the workflow — n8n → Workflows → ...Import from File.
  4. Get the webhook URL — open the Toss Webhook node to see its Test and Production URLs. Copy the Production URL. It only goes live once the workflow is activated.
  5. Register it with Toss — developer centre → webhooks → register → paste the Production URL → select the PAYMENT_STATUS_CHANGED event.
  6. Prepare the invoice vendor — sign up for your chosen service's test environment and obtain test credentials plus a test certificate if required. This has the longest lead time in the whole case. Start it first.
  7. Fill in the issuance node — add URL, auth headers, and request body per your vendor's reference.
  8. Prepare AlimTalk, or fall back to SMS — sender profiles and template review take days. Keep Send AlimTalk / SMS pointed at SMS while you build.
  9. Activate both triggers — the webhook flow and the scheduled flow both need to be on. Activate only one and you'll either accumulate an unprocessed queue or receive nothing at all.
  10. Test with a test payment — run one payment on test keys, then SELECT * FROM invoice_queue ORDER BY created_at DESC LIMIT 5; to confirm the row landed. Wait five minutes or hit Execute Workflow on the scheduled flow to watch it process.
  11. Verify the duplicate defence — send the same payment's webhook a second time and confirm the row count in invoice_queue is unchanged. Do not skip this. If it leaks, you issue duplicate tax invoices.

What happens in edge cases

  • The same webhook arriving repeatedlyON CONFLICT DO NOTHING ignores everything after the first. Since Toss retries up to seven times, this defence is mandatory.
  • Invoice issued but the message fails — the row never reaches done, attempts increments, and the next sweep retries it — which can issue a second invoice. In production, add a column such as invoice_issued_at, record issuance separately, and skip issuance on retry. That separation is not in this case's base workflow.
  • A permanently failing row — once attempts hits 5 it stops being claimed. The queue keeps moving, but a person needs to look at that row. Check SELECT * FROM invoice_queue WHERE attempts >= 5; periodically, or alert on it.
  • Cancellations and partial refunds — this workflow handles DONE only. Cancellation means an amended tax invoice, an entirely different legal procedure. Don't automate it; send a notification and let a person handle it.
  • A webhook exceeding 10 seconds — structurally impossible here, since the response is immediate. If the n8n instance is down there's no response at all, and Toss retries across up to seven attempts and roughly 3 days 19 hours. Restore the instance within that window and no payment is lost.
  • Integrating with the tax service directly — not recommended. Strict XML schemas, server certificates you manage yourself, and maintenance every time the regulations change. A relay service costs less in total.
10 sec
webhook response limit
7 retries
Toss redelivery
Zero
duplicate issuance
40 min
setup time
Please send a 200 response within 10 seconds.Toss Payments webhook documentation

Your operation belongs
in here, too.

Tell us the most repetitive task you have. We'll map an automation scenario for it.