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.
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
200within 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.
How it works
- A
Webhooknode receives Toss'sPAYMENT_STATUS_CHANGEDevent. Toss emits this on every status transition, so only completed payments (`DONE`) are kept. - A
Respond to Webhooknode returns 200 straight away. However long the rest takes, Toss already considers delivery successful. The 10-second limit stops being a constraint at all. - A
Codenode normalizes the payload and takespaymentKeyas the idempotency key. Anything that isn'tDONE, or has nopaymentKey, returns an empty array and ends quietly. - A
Postgresnode inserts intoinvoice_queuewithON CONFLICT DO NOTHING. A redelivered payment adds no row. This is the exact point where duplicate issuance is prevented. - A separate
Schedule Triggersweeps the queue every 5 minutes, claiming up to 20pendingrows and flipping them to `processing` in the same statement. Because it's one statement, overlapping runs can't claim the same row twice. - It calls the tax-invoice API, then sends AlimTalk (or SMS), then marks the row
done. On failureattemptsincrements, 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 withtest, 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);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.
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)
- Create the queue table — run the Step 1 SQL.
- Register the Postgres credential — n8n → Credentials →
Postgres→ Test for a green check. Docker n8n with a host database needshost.docker.internalas the host. - Import the workflow — n8n → Workflows →
...→ Import from File. - Get the webhook URL — open the
Toss Webhooknode to see its Test and Production URLs. Copy the Production URL. It only goes live once the workflow is activated. - Register it with Toss — developer centre → webhooks → register → paste the Production URL → select the
PAYMENT_STATUS_CHANGEDevent. - 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.
- Fill in the issuance node — add URL, auth headers, and request body per your vendor's reference.
- Prepare AlimTalk, or fall back to SMS — sender profiles and template review take days. Keep
Send AlimTalk / SMSpointed at SMS while you build. - 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.
- 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. - Verify the duplicate defence — send the same payment's webhook a second time and confirm the row count in
invoice_queueis unchanged. Do not skip this. If it leaks, you issue duplicate tax invoices.
What happens in edge cases
- The same webhook arriving repeatedly —
ON CONFLICT DO NOTHINGignores 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,attemptsincrements, and the next sweep retries it — which can issue a second invoice. In production, add a column such asinvoice_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
attemptshits 5 it stops being claimed. The queue keeps moving, but a person needs to look at that row. CheckSELECT * FROM invoice_queue WHERE attempts >= 5;periodically, or alert on it. - Cancellations and partial refunds — this workflow handles
DONEonly. 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.
Please send a 200 response within 10 seconds.— Toss Payments webhook documentation