TL;DR: Action Scheduler gives you two built-in ways to stop duplicate jobs — the $unique flag on the scheduling functions, and a guard with as_has_scheduled_action().
$unique = trueskips the insert if a matching action is already pending or in-progress, returning 0 instead of an action ID.as_has_scheduled_action( $hook, $args, $group )(since 3.3.0) returns true when a matching action already exists — match is exact on hook, args, and group.- Neither is fully race-proof.
$uniquecollapses the check and the insert into one call, so prefer it for events that can fire twice.
/ Two approaches
How do you prevent duplicate scheduled actions in Action Scheduler?
There are two supported approaches, and both compare actions on the same triple: hook name, arguments array, and group. The first is the $unique parameter on the scheduling functions — set it to true and Action Scheduler refuses to create a second action that matches one already queued. The second is as_has_scheduled_action(), which lets you check whether a matching action exists and branch on the result before you schedule.
Use $unique when a single user event can fire the same enqueue twice. Use as_has_scheduled_action() when you set up a recurring action once and want to avoid stacking it on every plugin upgrade. The full signatures live in the Action Scheduler API reference.
/ The flag
What does the $unique parameter do?
$unique is a boolean argument on as_enqueue_async_action(), as_schedule_single_action(), and as_schedule_recurring_action(). When it is true, Action Scheduler will not create the action if one with the same hook, args, and group is already pending or in-progress — the call returns 0 rather than a new action ID. Because the uniqueness check happens inside the same call that performs the insert, it is the tightest guard the API offers.
Note the argument order: $unique sits after $group and before $priority. If you only ever passed a hook and args before, you have to fill the $group slot (even with an empty string) to reach it.
PHP — signatures and a unique enqueue
// Signatures (Action Scheduler core) as_enqueue_async_action( $hook, $args = array(), $group = '', $unique = false, $priority = 10 ); as_schedule_single_action( $timestamp, $hook, $args = array(), $group = '', $unique = false, $priority = 10 ); // At most one sync per order can ever be pending: as_schedule_single_action( time() + 300, 'sync_order', array( $order_id ), 'orders', true // $unique );
/ The check
How does as_has_scheduled_action() check for duplicates?
It returns true if at least one action with the given hook — and optionally the given args and group — is currently pending or in-progress. It was added in Action Scheduler 3.3.0 and is the recommended way to guard recurring schedulers you register during activation or upgrade routines, so a plugin update never stacks a second copy of the same five-minute job.
Matching is exact. The args you pass must equal the args you scheduled with, element for element. Omit the args entirely to match any action on that hook. The Action Scheduler usage guide shows the canonical guard pattern.
PHP — guard a recurring action on activation/upgrade
// Run on init / activation — never stack the recurring action. if ( ! as_has_scheduled_action( 'my_cron_5min' ) ) { as_schedule_recurring_action( time(), 5 * MINUTE_IN_SECONDS, 'my_cron_5min' ); }
/ Which one
$unique vs as_has_scheduled_action — which should you use?
Use $unique for one-off enqueues fired from request handlers — order placed, form submitted, webhook received — where the same event can realistically fire twice in quick succession. Use as_has_scheduled_action() for recurring actions you register once in activation or upgrade code.
The reason is the race window. as_has_scheduled_action() is a check-then-act: another process can insert a matching action in the gap between your check returning false and your schedule call running. $unique has no gap — the check and the insert are one operation, so two concurrent requests cannot both win.
| Scenario | Raw dedup in your code | Webhook Actions |
|---|---|---|
| Same event fires twice | Two actions unless you set $unique or guard manually | Idempotent dispatch — one delivery per event |
| Recurring setup re-runs on upgrade | Guard every time with as_has_scheduled_action | Trigger bound once in the admin UI |
| Race between check and insert | Possible with check-then-schedule patterns | A single queued row per event |
/ Edge cases
Why do duplicates still slip through?
Four reasons cover almost every case. First, args mismatch: a single differing argument — an integer where you later pass a string, an extra element, a different order — makes the triple look unique, so normalize args before scheduling and checking. Second, group mismatch: checking without the group you scheduled with. Third, a genuine race when two concurrent requests both pass as_has_scheduled_action() before either inserts — the case $unique exists to close.
Fourth, and the one that surprises people: completed actions do not count. as_has_scheduled_action() only sees pending and in-progress actions, so a single action that already ran will not block you from scheduling it again. That is usually the behaviour you want, but it means it is not a historical "has this ever run" check.
Action Scheduler's idea of "the same action" is an exact match on hook, args, and group. Change one byte of the args array and it is a brand-new action. — Action Scheduler API
The Webhook Actions plugin binds a trigger to a webhook once in the admin UI and dispatches one delivery per event — so you get idempotent outbound webhooks without writing $unique guards by hand. See the Action Scheduler PHP API reference for the rest of the scheduling surface.
as_has_scheduled_action(): actionscheduler.org/usage.