Putting a human between an AI draft and the publish button sounds like a one-node job in n8n. Drop in a Wait node set to "resume on webhook", hand someone the resume URL, done. And for a demo, it is.
The hard part is the boundary: n8n sends a draft to something that holds it, a person decides, and that something calls n8n back. Every hop can fail without throwing an error. You just end up with a duplicate draft, a decision that never arrived, or an endpoint that will call any URL you give it.
My setup: n8n drafts a post with an LLM, signs it, posts it to a small ASP.NET Core minimal API on .NET 10, then waits. A reviewer approves (optionally with edits), rejects, or asks for changes, and the API resumes the workflow. The proof project has 32 passing tests, and I ran it end to end against a local n8n 2.43.8 (with a stub LLM, more on that at the end). Below are the six ways this breaks, in the order a draft travels through the system. The code is trimmed to the shape of each fix; the omitted parts are marked.
If you want ready-made n8n flows to put a gate like this in front of, the Ultimate n8n Workflow Pack has 2,000+ importable workflows. For complete, tested .NET projects, there's Tech Skill Builder.
Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10.
The shape, so we agree on terms
n8n: topic -> LLM draft -> sign -> POST /drafts -> Wait (resume on webhook)
API: store draft (pending) -> human decides -> POST to the Wait node's resume URL
n8n: approved -> publish | changes_requested -> redraft (max 3) | rejected or timeout -> stop
n8n owns the flow. The .NET API owns the decision. Keep that split in mind, because most of the failures below come from blurring it.
Failure 1: The signature breaks because the JSON got re-serialized
The first thing you do is sign the request with HMAC so only your n8n can submit drafts. The classic way that breaks is the signature itself: n8n builds an object, you sign JSON.stringify of it, and then the HTTP node serializes the object again on send. Different whitespace or key order, different bytes, invalid signature, and you spend an hour blaming the secret.
The fix: build the JSON string once in a Code node, sign that exact string, and send it as a raw body. On the .NET side, verify against the raw request bytes, before any model binding touches them. My submit endpoint takes no [FromBody] parameter at all; an endpoint filter reads the bytes, checks the signature, and hands the verified bytes to the handler.
public sealed class SignedBodyFilter(IOptions<GateOptions> options, TimeProvider time, ILogger<SignedBodyFilter> logger)
: IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
// 1. parse X-Signature-Timestamp, reject if outside the window
// 2. read the raw body with a size cap (413 if too big)
// 3. HmacSignature.Verify(secret, timestamp, body, X-Signature header) or 401
// 4. stash the bytes for the endpoint: http.Features.Set(new VerifiedBody(body))
// (implementation omitted here)
}
}
The check: a test signs a body, changes one character, and expects 401. Another computes the signature with Python's hmac module and asserts the C# code produces the same hex, so I know the scheme matches something other than itself.
Failure 2: A valid request can be replayed forever
Signing the body alone proves who sent it, not when. Anyone who captures one signed request can resend it next week.
The fix: sign the timestamp together with the body, as "{unixSeconds}.{rawBody}", and reject anything older (or newer) than a window. I use 300 seconds. Because the timestamp is inside the signed string, an attacker can't swap in a fresh one without breaking the signature. Compare with CryptographicOperations.FixedTimeEquals, never ==.
The check: with a FakeTimeProvider, sign a request, advance the clock 6 minutes, send it: 401. A request timestamped 4 minutes ago: accepted. A timestamp 10 minutes in the future: 401. Inject TimeProvider from day one; time windows tested with the real clock are flaky tests.
Failure 3: n8n's retry creates a second draft
The HTTP node that submits the draft has "Retry On Fail" turned on (3 tries), because networks fail. The catch: if the API stored the draft but the response timed out, n8n sends it again, and now the reviewer sees two copies of the same draft.
The fix: an idempotency key that n8n can compute the same way on every retry. Mine is the execution id plus the revision number:
draftKey: `exec-${$execution.id}-r${state.revision}`, // same execution + revision = same draft
The API stores a SHA-256 of the content next to the key, which gives three outcomes instead of two:
| Same key? | Same content? | Response |
|---|---|---|
| no | - | 201 Created |
| yes | yes | 200 with the original draft (an honest retry) |
| yes | no | 409 Conflict (a reused key is a bug, say so) |
lock (_gate)
{
if (_byKey.TryGetValue(draft.DraftKey, out var existingId))
{
var existing = _byId[existingId];
return new SubmitResult(existing.ContentHash == hash ? SubmitOutcome.Duplicate : SubmitOutcome.KeyConflict, existing);
}
// create the draft (Guid v7 id, status pending) and index it by key
// (rest of InMemoryDraftStore omitted here)
}
"Check the key, then insert" has to be one atomic step. In production that's a unique index on the key in a real table.
Failure 4: Two reviewers, two decisions
One reviewer clicks approve while another clicks reject. Without rules, the last write wins, and n8n may get resumed twice with two different answers.
The fix: a tiny state machine. A draft can only leave pending, and only once. approved, rejected and changes_requested are final. A revision is not an edit of the old draft; it's a new draft with a new key (...-r2) that n8n submits after it loops back. On top of the state rules, two small validation rules: edited text is only accepted with approve, and "request changes" requires a note, because that note is what the LLM gets as feedback.
private static readonly Dictionary<DraftStatus, DraftStatus[]> Allowed = new()
{
[DraftStatus.Pending] = [DraftStatus.Approved, DraftStatus.Rejected, DraftStatus.ChangesRequested],
[DraftStatus.Approved] = [],
[DraftStatus.Rejected] = [],
[DraftStatus.ChangesRequested] = [],
};
// Apply(): transition check, edit/note rules, final text = edited text or the AI body
// (Apply omitted here)
The check: 32 parallel tasks try to approve or reject the same draft. Exactly one succeeds, 31 are rejected. At the API level, a second approve gets 409 and the fake n8n handler records exactly one resume call.
Failure 5: The callback fails and the decision is lost
The reviewer clicks approve, the API calls n8n, n8n is restarting. If your code decides and notifies in one step, that approval is gone, and the reviewer has no idea.
The fix: save first, call second. The decision is stored before the resume call is made. The call goes through a typed HttpClient with AddStandardResilienceHandler (3 retries), and its outcome (delivered or failed, plus attempts and the error) is recorded on the draft. If it still fails, a POST /drafts/{id}/redeliver endpoint sends the stored decision again, and refuses if the draft is still pending or already delivered.
public async Task<IResult> DecideAsync(Guid id, Decision decision, DecisionRequest? request, string reviewer, CancellationToken ct)
{
var result = store.Decide(id, decision, request?.EditedText, request?.Note, reviewer, time.GetUtcNow());
// NotFound -> 404, illegal transition -> 409, bad input -> 400
// decided -> NotifyAsync: resume.SendAsync(draft), then store.RecordCallback(...)
// (mapping and NotifyAsync omitted here)
}
The check: script the fake n8n to answer 503, then 500, then 200: the draft ends delivered after 3 calls. Script it to always answer 502: the draft is still approved, the callback is failed after 4 calls (1 + 3 retries), and a redeliver later succeeds.
One trap from this part: my retry tests first hung for over a minute. I had replaced TimeProvider with a FakeTimeProvider in DI, the resilience pipeline picked it up, and since nothing advanced the fake clock, the retry delay never ended. The fix was a 0 ms retry delay in the test configuration only.
Failure 6: Your API will call any URL it's given
The resume URL arrives in the draft payload, because n8n generates it per execution ($execution.resumeUrl). That means a request body decides which URL your server calls later. If someone gets a signed request through, or your secret leaks, your API becomes a tool for calling internal addresses.
The fix: an allow-list. Gate:AllowedResumeUrlPrefixes must contain your n8n host, and any resume URL that doesn't start with one of them is a 400 at submit time. As a second layer, the Wait node uses Header Auth, and the API sends that header on the resume call. And treat the resume URL like a secret: in n8n 2.x it carries a signature token in the query string, so store the full URL and never echo it back in reviewer responses. My reviewer DTOs don't include it.
The check: submit a draft whose resume URL is http://169.254.169.254/latest/meta-data/ and expect 400.
What the end-to-end run showed, and what it didn't
Against a real local n8n 2.43.8, I asked for changes on one run, it looped back to revision 2, and I approved that with edited text: Publish received my text, not the AI's. Two rejected runs ended at Stop.
What I did not verify: a real LLM (the run used a small local stub that speaks the OpenAI chat format, so it proves the plumbing, not the writing), the Wait node's 48-hour timeout branch, and n8n queue mode with workers. The store is in memory.
A pre-flight checklist
Before I'd let an AI pipeline publish anything behind a human gate:
- [ ] Is the signed string exactly the string that's sent, and verified over raw bytes?
- [ ] Is a timestamp inside the signature, with a short window?
- [ ] Does every submission carry an idempotency key that survives n8n retries?
- [ ] Can a decision happen only once, even with two reviewers racing?
- [ ] Is the decision saved before n8n is called, with a way to redeliver?
- [ ] Are resume URLs restricted to your own n8n host?
- [ ] Is there a cap on revision loops? (Mine stops after 3.)
Where to go from here
If you don't want to build every flow around a gate like this from scratch, the Ultimate n8n Workflow Pack gives you 2,000+ ready-to-use n8n workflows as importable JSON for a one-time $20, across 50+ categories (AI and content, CRM, email, social media, e-commerce, finance and more) and 400+ integrations, with error handling and retries already built into many of them. Import one, add your credentials, and put an approval step in front of its publishing node.
And if you learn best from complete code you can run, Tech Skill Builder gives you complete, working, tested .NET projects with full source code and step-by-step PDF guides, with new ones added regularly. Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10. Join Tech Skill Builder
Where does your AI pipeline hand off to a human today, and which of these six would it fail first?
Top comments (1)
the 48h timeout branch you didnt get to is the one that bit us. reviewer approves on monday a post that was meant for friday's launch, workflow resumes, and it publishes straight away because nothing checks the slot anymore. we ended up storing the intended publish time with the draft and having the api refuse to resume as approved if that time already passed (it goes back as changes_requested with a note like "missed slot, pick a new time"). boring fix but it stopped a couple of awkward holiday posts going out in january
also +1 on save first, call second. learned that one the hard way too