Recipe: Hook Into Checkout
Goal: Run code at the real moments in the paid-order lifecycle: before an order is created (to validate or modify), when an order becomes paid, and when an order is refunded or cancelled.
Free vs Pro. The order pipeline lives in Free (
OrderService), so these hooks fire on the Free plan too, but a paid ticket only settles when a gateway is wired (a Pro feature). On Free alone, $0 orders settle inline; a paid ticket returns402until a gateway is registered. See Recipe: Extend with a Provider for how a gateway is actually wired.
Seams Used
| Seam | Type | When it fires |
|---|---|---|
evnm_before_create_order |
Filter ( $order, $context ) |
Before the order row is inserted. Return WP_Error to abort. |
evnm_after_create_order |
Action ( $order, $context ) |
After the order row is inserted (status pending, or paid for a $0 order). |
evnm_after_update_order |
Action ( $order, $changed_keys, $context ) |
When an order field changes, including the status flip to paid, refunded, or cancelled. |
evnm_after_reverse_order |
Action ( $order_id, $context ) |
After Free releases reserved stock + voids RSVPs on a refund/cancel. |
There is no
evnm_before_refund_orderorevnm_after_refund_order; those hooks do not exist. Refunds flow through the ordinary order-update path (evnm_after_update_orderwithstatus→refunded), and Free's own reversal firesevnm_after_reverse_orderafterwards.
Step 1: Validate Before Creation
evnm_before_create_order receives the assembled order row (columns → values), not the raw request. Return the array to proceed, or a WP_Error to abort the whole checkout.
add_filter( 'evnm_before_create_order', function ( array $order, array $context ) {
// Block orders from a specific email domain.
$email = (string) ( $order['email'] ?? '' );
if ( str_ends_with( $email, '@blocked.example.com' ) ) {
return new \WP_Error(
'my_addon_blocked_email',
__( 'Orders from this email domain are not accepted.', 'my-addon' ),
[ 'status' => 422 ]
);
}
return $order;
}, 10, 2 );
The $order row contains: order_number, event_id, user_id, email, name, subtotal, total, currency, gateway, status, idempotency_key, and meta. There is no line_items column; see Where the Purchased Tickets Live below.
Step 2: React When an Order Is Paid
evnm_after_update_order fires whenever any order field changes. Check $changed_keys for 'status' and confirm the new status is 'paid':
add_action( 'evnm_after_update_order', function ( array $order, array $changed_keys, array $context ): void {
if ( ! in_array( 'status', $changed_keys, true ) || 'paid' !== ( $order['status'] ?? '' ) ) {
return;
}
// Reserved seats are recorded in the order meta, not a column.
$reserved = $order['meta']['reserved'] ?? [];
foreach ( $reserved as $line ) {
my_addon_grant_perk( (int) $order['user_id'], (int) $line['ticket_id'], (int) $line['qty'] );
}
my_addon_push_order_to_crm( $order );
}, 10, 3 );
Step 3: React When an Order Is Refunded or Cancelled
A refund or cancellation is a status change like any other. Free itself listens on evnm_after_update_order, and when an order transitions into refunded/cancelled it releases the reserved stock, voids the order's RSVPs, then fires evnm_after_reverse_order. Hook that action for your own downstream cleanup; by the time it runs, Free has already reversed its own data:
add_action( 'evnm_after_reverse_order', function ( int $order_id, array $context ): void {
$repo = evnm( \Eventonomy\Contracts\OrderRepositoryInterface::class );
$order = $repo->get( $order_id );
if ( ! is_array( $order ) ) {
return;
}
my_addon_revoke_perk( (int) $order['user_id'], $order_id );
my_addon_log_refund( $order_id, (float) $order['total'], (string) $order['currency'] );
}, 10, 2 );
Where the Purchased Tickets Live
The order row has no line_items column. The authoritative record of what was bought is the reserved-seat list Free stores in the order meta at insert time:
// $order['meta']['reserved'] - one entry per reserved ticket type:
[
[ 'ticket_id' => 42, 'qty' => 2 ],
[ 'ticket_id' => 51, 'qty' => 1 ],
]
Separately, the input to POST /orders carries line_items in this shape (this is request input, not stored on the order):
// Each element of the request's line_items[]:
[
'ticket_id' => 42,
'qty' => 2,
'price' => 25.00, // dollars (major units) - DISPLAY ONLY
]
priceis never trusted. Free re-prices every line from the event's own active tickets server-side (a craftedprice: 0mints nothing). Readmeta['reserved']to know what was actually reserved, and$order['total']for the charged amount. Amounts are in major units (dollars); format withevnm_format_money().
Reading Order Data
Always resolve the order repository through the contract, never new a concrete class:
$repo = evnm( \Eventonomy\Contracts\OrderRepositoryInterface::class );
$order = $repo->get( $order_id );
Verify It Worked
- Create a test event with a paid ticket and a wired gateway (see Recipe 12; with Pro + Stripe, use test card
4242 4242 4242 4242). - Complete checkout and confirm your
evnm_after_update_ordercallback fired; checkwp-content/debug.logor your CRM/perk store. - Reverse the order and confirm
evnm_after_reverse_orderfired and your cleanup ran. Either path reverses: refund it with Pro's Refund button orPOST /orders/{id}/refund, or cancel it withPATCH /orders/{id}and{"status":"cancelled"}. Note thatPATCHcannot setrefunded- it accepts onlypaidandcancelled, and anything else returns422. Refunding is Pro'sRefundService, which owns that status.
Related
docs/EXTENDING.md§2.2 - RSVP / Ticket / Order / Occurrence hook signatures.- Payment Gateways - gateway setup.
- Recipe: Extend with a Provider - wiring a gateway via
evnm_payment_gateways+evnm_process_payment. - Recipes Index