Eventonomy

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 returns 402 until 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_order or evnm_after_refund_order; those hooks do not exist. Refunds flow through the ordinary order-update path (evnm_after_update_order with statusrefunded), and Free's own reversal fires evnm_after_reverse_order afterwards.

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
]

price is never trusted. Free re-prices every line from the event's own active tickets server-side (a crafted price: 0 mints nothing). Read meta['reserved'] to know what was actually reserved, and $order['total'] for the charged amount. Amounts are in major units (dollars); format with evnm_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

  1. 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).
  2. Complete checkout and confirm your evnm_after_update_order callback fired; check wp-content/debug.log or your CRM/perk store.
  3. Reverse the order and confirm evnm_after_reverse_order fired and your cleanup ran. Either path reverses: refund it with Pro's Refund button or POST /orders/{id}/refund, or cancel it with PATCH /orders/{id} and {"status":"cancelled"}. Note that PATCH cannot set refunded - it accepts only paid and cancelled, and anything else returns 422. Refunding is Pro's RefundService, which owns that status.

Related