An approval answers a narrow question: did the right person agree to this request, as it stood when they looked at it? Most broken approval designs get the first half right (the right person clicked Approve) and lose the second half. The requester raises the amount after the manager approves, the record still says Approved, and six months later an auditor asks who signed off on 4,200 dollars. Nobody did.
This guide covers where ServiceNow stores approval decisions, how the approval rules in Flow Designer combine, and how to keep each decision tied to the version that was reviewed.
Where approval decisions live
Each approver gets one row in the Approval table, sysapproval_approver. The row points at the record being approved (the sysapproval reference for task-based records, plus a document ID for other tables), names the approver, and carries a state: Not Yet Requested, Requested, Approved, Rejected, Cancelled, No Longer Required or Skipped. Group approvals add a sysapproval_group row and then individual rows for the members.
The record being approved, usually something on the task hierarchy such as a requested item or change request, has its own approval field holding the overall result. That split matters in interviews: the per-person rows are the evidence, the parent field is the summary. Reports that only look at the parent field can’t tell you who decided, when, or with what comment.
Approve when, reject when
The Ask for Approval action in Flow Designer (part of Workflow Studio in current releases) lets you configure the decision without script. Its approval rules include:
- Anyone approves: the first approval from the listed users wins.
- All users approve: everybody listed has to approve.
- All responded and anyone approves: wait for every response, then one approval is enough.
- % of users approve and # of users approve: a threshold, for example 2 of 3.
Approvers can be users, groups, a field on the record (such as the requester’s manager) or manual approvers added to the Approvers related list. Rules can be combined into rule sets, and you define rejection rules as a separate OR rule set.
Two settings separate a working design from one that strands records. First, always add a rejection rule. The documentation is explicit that if you set an approval rule without a rejection rule (or the other way round) and the expected state is never met, the result is Cancelled, not Rejected, which is rarely what the business means. Second, set a Due Date. Without one, an approval sent to someone who has left the company waits indefinitely. Whether the timeout approves, rejects or cancels is a business decision; for spending, rejection or escalation is the usual choice, while some low-risk operational approvals auto-approve so service isn’t blocked.
What did the approver actually approve?
Here is the gap most designs leave open. The approval row records who decided and when, but it doesn’t automatically record the request values the approver saw. If the requester edits the amount, vendor or cost centre after approval, nothing in the platform forces a new decision.
Two habits close the gap:
- Snapshot the reviewed values. Put the material values into the approval’s Approval Reason input (which is stored on the approval record), for example “Laptop purchase: 1,800 USD, vendor Dell, cost centre CC-410”. Now the evidence row says what was agreed.
- Define material fields and reset on change. Decide which fields change the meaning of the request. An edited amount does; a corrected typo in the justification doesn’t. When a material field changes while an approval is requested or approved, cancel the outstanding decisions and ask again.
A model of rules and re-approval
The logic is easier to reason about away from the platform. This Node.js model (no ServiceNow APIs involved) fingerprints the material fields, evaluates “manager approves AND 2 of 3 finance approvers”, rejects on the manager’s rejection or two finance rejections, and starts a new round when the fingerprint changes:
const crypto = require('node:crypto');
const MATERIAL = ['amount', 'vendor', 'cost_center'];
const fingerprint = (req) =>
crypto.createHash('sha256').update(JSON.stringify(MATERIAL.map((f) => req[f]))).digest('hex').slice(0, 8);
// Rule sets are ORed; conditions inside a set are ANDed.
const approveWhen = [[{ who: ['alice'], need: 'all' }, { who: ['fin1', 'fin2', 'fin3'], need: 2 }]];
const rejectWhen = [[{ who: ['alice'], need: 'any-reject' }], [{ who: ['fin1', 'fin2', 'fin3'], need: 'two-rejects' }]];
function met(cond, votes) {
const v = cond.who.map((u) => votes[u]);
if (cond.need === 'all') return v.every((x) => x === 'approved');
if (cond.need === 'any-reject') return v.some((x) => x === 'rejected');
if (cond.need === 'two-rejects') return v.filter((x) => x === 'rejected').length >= 2;
return v.filter((x) => x === 'approved').length >= cond.need;
}
const outcome = (votes) =>
rejectWhen.some((set) => set.every((c) => met(c, votes))) ? 'rejected'
: approveWhen.some((set) => set.every((c) => met(c, votes))) ? 'approved' : 'requested';
const req = { number: 'PR0001042', amount: 1800, vendor: 'Dell', cost_center: 'CC-410' };
let round = { n: 1, fp: fingerprint(req), votes: {} };
const log = (msg) => console.log(`round ${round.n} [${round.fp}] ${msg.padEnd(34)} -> ${outcome(round.votes)}`);
function vote(user, decision) { round.votes[user] = decision; log(`${user} ${decision}`); }
function edit(field, value) {
req[field] = value;
const fp = fingerprint(req);
if (fp !== round.fp) {
console.log(` ${field} changed: ${Object.keys(round.votes).length} decision(s) cancelled, new round`);
round = { n: round.n + 1, fp, votes: {} };
}
log(`edit ${field}=${value}`);
}
vote('alice', 'approved');
edit('amount', 4200);
edit('justification', 'add dock'); // not material, no reset
vote('alice', 'approved');
vote('fin1', 'approved');
vote('fin2', 'rejected');
vote('fin3', 'approved');round 1 [9e03c249] alice approved -> requested
amount changed: 1 decision(s) cancelled, new round
round 2 [03566b7e] edit amount=4200 -> requested
round 2 [03566b7e] edit justification=add dock -> requested
round 2 [03566b7e] alice approved -> requested
round 2 [03566b7e] fin1 approved -> requested
round 2 [03566b7e] fin2 rejected -> requested
round 2 [03566b7e] fin3 approved -> approvedWalk through it. Alice’s first approval was for 1,800; when the amount jumps to 4,200 her decision is cancelled, not carried over, because she never saw the new figure. The justification edit doesn’t touch a material field, so round 2 continues. One finance rejection isn’t enough to reject under the “two rejections” rule, and the request is approved once the second finance approval arrives. Change any rule and rerun it to see the effect; it’s a cheap way to settle arguments about thresholds before anyone builds the flow.
Wiring re-approval into a flow
On the platform, the same design has three pieces.
The flow’s record trigger should run Once. ServiceNow’s guidance is that flows with approval actions shouldn’t run on every update, because each update would start another context and another set of approval requests. Don’t put two Ask for Approval actions for the same record in parallel branches either; the documentation calls that unsupported because the branches depend on each other.
To ask again, use Go back to flow logic: after the approval step, the flow waits for fulfilment to start or for a “needs re-approval” signal, and loops back to the approval step when it sees the signal.
The signal comes from server logic that watches material fields. An illustrative after business rule (not run here, since there is no instance behind this article):
// After update on u_purchase_request
// Condition: current.u_amount.changes() || current.u_vendor.changes() || current.u_cost_center.changes()
(function executeRule(current, previous) {
if (current.approval != 'requested' && current.approval != 'approved') return;
var appr = new GlideRecord('sysapproval_approver');
appr.addQuery('sysapproval', current.getUniqueValue());
appr.addQuery('state', 'IN', 'requested,approved');
appr.query();
while (appr.next()) {
appr.setValue('state', 'cancelled');
appr.comments = 'Cancelled: amount changed from ' + previous.u_amount + ' to ' + current.u_amount;
appr.update();
}
gs.eventQueue('u_purchase_request.reapproval', current, previous.u_amount, current.u_amount);
})(current, previous);It’s an after rule because it updates other records, and it records the reason on each cancelled approval so the history explains itself. Test the exact cancellation behaviour in a sub-production instance; how a running flow reacts to cancelled approval rows depends on how the flow is built.
Self-approval, delegation and timeouts
A few edge cases come up in every serious review:
- Self-approval. If the requester belongs to the approving group, a plain group approval can send them a request for their own purchase. Exclude the requester explicitly when you build the approver list, and test it with a requester who is in the group.
- Delegation. Approvers often delegate approvals while on leave. Make sure your audit trail can answer who actually acted, the approver of record or the delegate, and that a delegate can’t approve something they requested.
- Inactive approvers. By default Ask for Approval still generates approvals for inactive users and groups (a system property controls this), so a due date and an escalation path matter more than they first appear.
Interview practice
A change request was approved by the CAB on Monday. On Wednesday the implementer widened the planned window from 1 hour to 6 hours and added two more servers. The change still shows Approved and goes ahead on Friday. What went wrong in the design, and what would you change?
Answer
The approval was treated as a property of the record instead of a decision about specific values. The CAB approved a one-hour change on a defined set of configuration items; nothing tied that decision to those values, so the material edits slipped through.
I’d first define which fields are material for a change (planned start and end, affected CIs, risk, implementation plan) and record the reviewed values in the approval reason or a snapshot. Then I’d add server-side logic that, when a material field changes on an approved or requested change, cancels the outstanding approvals with a comment explaining why and sends the change back to Assess or Authorize. Approvals stay with the flow, triggered once, using Go back to logic for the second round. Finally I’d make the evidence visible: a report on approvals cancelled for material change shows whether people are editing after approval routinely, which is a process problem as much as a tooling one.
Related reading
- ServiceNow interview questions
- ServiceNow multiple-choice questions
- ServiceNow Notification Controls, for the events that tell approvers a new round has started
- ServiceNow Record Producers, where many approval-bearing requests begin
- Ask for Approval action (ServiceNow documentation)