Docs  /  Developer Guide  /  Hooks

Hooks

Real scenarios, real code. Each hook gets a use case, an arg table, working code, and the tradeoffs you only learn the hard way.

How to read this page

This page covers the hooks you’ll actually reach for. Each one starts with the kind of problem you’d use it to solve, then walks through what fires it, what you get to work with, and a real working example. The complete A–Z list with every hook’s signature lives in Reference → All hooks.

A word on style: every example here uses a named function or a class method. Anonymous closures attached with add_action are an anti-pattern in WordPress plugin code — you can’t un-register them with remove_action(), they’re awkward to test, and a child addon can’t override them. The few seconds you save writing a closure cost you hours when something needs to change later.

Run code when an update is created or edited

order_updates_for_woo_after_update_save

You run support through a shared Slack channel and you want the team to see every new update the moment it’s opened — without piling more email into people’s inboxes. This hook fires the instant an update is saved, whether it was created from the admin meta box, the customer portal, or a custom REST call.

When it fires

  1. Someone publishes the “Add new update” modal, or edits an existing one.
  2. The plugin writes the row to the database, sets up the assignee, and queues any notification emails.
  3. Right before returning the REST response, this hook fires.

What you get

ArgumentTypeWhat it is
$update_idintThe ID of the update that was just saved.
$updatearrayThe full update record after save — title, status, assignee, customer-visible flag, all of it.
$is_editbooltrue if this was an edit of an existing update, false on first creation.
$requestWP_REST_RequestThe originating REST request — useful if you need the raw input.

The code

add_action( 'order_updates_for_woo_after_update_save', 'my_addon_post_to_slack', 10, 4 );

function my_addon_post_to_slack( int $update_id, array $update, bool $is_edit, WP_REST_Request $request ): void {
    // Edits don't need a Slack ping -- only new updates.
    if ( $is_edit ) {
        return;
    }

    $order  = wc_get_order( (int) $update['order_id'] );
    $title  = (string) $update['title'];
    $admin  = admin_url( 'admin.php?page=wc-orders&action=edit&id=' . (int) $update['order_id'] );

    My_Addon\Slack::post( '#support', sprintf(
        ":envelope: New update on order #%s: *%s* (%s)\n%s",
        $order ? $order->get_order_number() : $update['order_id'],
        $title,
        get_userdata( get_current_user_id() )->display_name,
        $admin
    ) );
}

Things worth knowing

The $is_edit flag is your friend. Without that check, every status change, title rename, or visibility flip would re-ping Slack. Most teams find that noisy. If you want edits to ping too — useful for assignment changes, say — flip the condition.

Slack throwing means the REST request waits. If your Slack workspace is having a bad day and the HTTP call takes 8 seconds, the admin saving the update sees an 8-second spinner. Either keep the Slack call fast or move it to its own Action Scheduler job. AsyncJob::queue() is right there.

Use this for “just created” events. For “just resolved”, “just rated”, or “just reassigned”, there are more specific hooks below — they fire at the right moment with the right context.

Run code when an update is marked solved

order_updates_for_woo_after_mark_solved

Your boss wants weekly numbers: how fast is the team closing tickets? The plugin tracks when an update was created and when it was solved, but not the gap. Fire-and-forget the duration to your analytics system whenever a solve happens.

When it fires

  1. An admin clicks Mark as solved on the update card, or a customer reopens-then-resolves via the portal, or a script POSTs to /updates/{id}/solve.
  2. The plugin flips is_resolved=1, sets solved_at, queues the resolution email (if “notify customer” was picked), and logs a status-change row in the tracking log.
  3. This hook fires.

What you get

ArgumentTypeWhat it is
$update_idintThe update that just got solved.
$updatearrayThe full update record after the solve. created_at and solved_at are both populated.
$requestWP_REST_RequestThe originating request.

The code

class My_Addon_Resolution_Tracker {

    public static function init(): void {
        add_action( 'order_updates_for_woo_after_mark_solved', [ self::class, 'on_solved' ], 10, 3 );
    }

    public static function on_solved( int $update_id, array $update, WP_REST_Request $request ): void {
        $created = strtotime( (string) $update['created_at'] );
        $solved  = strtotime( (string) $update['solved_at'] );

        if ( ! $created || ! $solved ) {
            return;
        }

        My_Addon\Analytics::send( 'update.resolution_time_minutes', round( ( $solved - $created ) / 60 ), [
            'update_id'  => $update_id,
            'order_id'   => (int) $update['order_id'],
            'assignee'   => (int) $update['assignee_user_id'],
            'solver'     => get_current_user_id(),
        ] );
    }
}

My_Addon_Resolution_Tracker::init();

Things worth knowing

Class methods over standalone functions. For anything more than a one-liner, wrap your handler in a class. You get auto-namespacing (no my_addon_ prefix wars), it’s easier to unit-test, and a child addon can extend the class to override behaviour.

Reopens-then-solves count too. If a customer reopens via “Still has issue?” and then the team resolves again, this hook fires again. You’ll get two data points for the same update id. That’s usually what you want for resolution-time tracking — the second solve is a separate cycle.

The customer’s rating arrives later. If you want to correlate resolution time with customer satisfaction, hook after_customer_rating as well and join by update_id in your analytics warehouse.

Run code when an update is re-opened

order_updates_for_woo_after_reopen_update

A customer hitting “Still has issue?” after you marked a ticket solved is a signal worth catching. It usually means the team thought it was done but the customer doesn’t agree. You want eyes on it within the hour.

When it fires

  1. An admin clicks Re-open, or the customer clicks Still has issue? on their portal.
  2. The plugin flips is_resolved=0, logs a system row in the tracking log, and (if the original solve queued a rating-request email) wipes that email’s pending state.
  3. This hook fires.

What you get

ArgumentTypeWhat it is
$update_idintThe reopened update.
$updatearrayFull record after reopen. is_resolved is now 0; solved_at is preserved so you can see the original solve time.
$requestWP_REST_RequestThe originating request — check this to tell admin reopens from customer ones.

The code

add_action( 'order_updates_for_woo_after_reopen_update', 'my_addon_page_oncall_on_customer_reopen', 10, 3 );

function my_addon_page_oncall_on_customer_reopen( int $update_id, array $update, WP_REST_Request $request ): void {
    // Only page when the customer reopened. Admin reopens are usually planned.
    $is_customer_action = (bool) $request->get_param( 'order_key' );

    if ( ! $is_customer_action ) {
        return;
    }

    My_Addon\PagerDuty::trigger( 'customer-reopen', sprintf(
        'Customer reopened update %d on order %d. Last solved %s.',
        $update_id,
        (int) $update['order_id'],
        (string) $update['solved_at']
    ) );
}

Things worth knowing

How to tell admin from customer. Customer-side requests always carry an order_key parameter — that’s the credential guests use. Staff requests don’t. Checking for it is the cleanest way to branch.

The reopen email pipeline. The plugin does not automatically email the assignee on a reopen — it just shows up in the tracking log. If you want an email, queue your own via AsyncJob::queue() or hook order_updates_for_woo_after_add_customer_note (a customer-initiated reopen always includes a note).

Run code when an update is deleted

order_updates_for_woo_after_delete_update

Compliance team wants a 7-year archive of every customer interaction, even ones you’ve cleaned out of the live database. The plugin already keeps a snapshot on the order’s edit page (Deleted Updates meta box), but that lives in WordPress — if the order itself gets deleted, the audit goes with it. Mirroring to S3 (or whatever you use) gets you out of that bind.

When it fires

  1. Someone clicks Delete on an update card, or POSTs to DELETE /updates/{id}.
  2. The plugin removes the row from the live tables and writes a snapshot to the order’s deletion-audit log.
  3. This hook fires. The DB row is gone — but $update still has the full pre-delete record for you.

What you get

ArgumentTypeWhat it is
$update_idintThe deleted update’s ID.
$updatearrayThe snapshot taken just before deletion. Use this — not a fresh DB lookup, the row is gone.
$requestWP_REST_RequestThe originating request.

The code

add_action( 'order_updates_for_woo_after_delete_update', 'my_addon_archive_to_s3', 10, 3 );

function my_addon_archive_to_s3( int $update_id, array $update, WP_REST_Request $request ): void {
    $key = sprintf( 'order-updates/%d/%d-%s.json',
        (int) $update['order_id'],
        $update_id,
        gmdate( 'Y-m-d-His' )
    );

    My_Addon\S3::put( $key, wp_json_encode( [
        'update'     => $update,
        'deleted_by' => get_current_user_id(),
        'deleted_at' => current_time( 'mysql', true ),
    ], JSON_PRETTY_PRINT ) );
}

Things worth knowing

Don’t try to query the DB for the update. By the time this hook fires the row is gone. The $update snapshot is the only source of truth — grab whatever you need from it.

Attachments are still on disk — briefly. The plugin schedules an attachment cleanup right after this hook. If your archive needs the files, you have to copy them inside this handler. Hesitate and they’re gone.

Run code when an internal note is added (@mentions)

order_updates_for_woo_after_add_internal_note

Your team works in Slack more than in WordPress. Email mentions are fine, but a Slack DM gets seen faster. Forward any internal note that mentions a teammate directly to their DM.

When it fires

  1. An admin posts an internal note via the Internal Notes tab, or POSTs to /updates/{id}/notes.
  2. The plugin saves the note, scans for @username mentions, queues mention emails, and updates each mentioned user’s admin-bar badge.
  3. This hook fires.

What you get

ArgumentTypeWhat it is
$note_idintThe new note’s ID.
$update_idintThe update it’s attached to.
$notestringThe note body, post-sanitization. Mentions are inline as @username.
$requestWP_REST_RequestThe originating request.

The code

add_action( 'order_updates_for_woo_after_add_internal_note', 'my_addon_slack_dm_mentions', 10, 4 );

function my_addon_slack_dm_mentions( int $note_id, int $update_id, string $note, WP_REST_Request $request ): void {
    // Pull every @username from the note body.
    if ( ! preg_match_all( '/@([a-z0-9_-]+)/i', $note, $matches ) ) {
        return;
    }

    foreach ( array_unique( $matches[1] ) as $username ) {
        $user = get_user_by( 'login', $username );
        if ( ! $user ) {
            continue;
        }

        $slack_id = (string) get_user_meta( $user->ID, 'my_addon_slack_id', true );
        if ( '' === $slack_id ) {
            continue;
        }

        My_Addon\Slack::dm( $slack_id, sprintf(
            'You were mentioned on order update %d by %s:> %s',
            $update_id,
            wp_get_current_user()->display_name,
            wp_trim_words( wp_strip_all_tags( $note ), 30 )
        ) );
    }
}

Things worth knowing

Match the plugin’s mention syntax. The plugin recognises @username where username is the WP login. Match anything looser and you’ll DM people who weren’t actually tagged.

Map WP users to Slack IDs ahead of time. The example stores the Slack member ID in user meta — build a small settings screen where each user pastes theirs in once. Don’t try to email-lookup at runtime; Slack’s API rate-limits get angry fast.

Run code when a customer note is added

order_updates_for_woo_after_add_customer_note

You run support in WordPress but the sales team lives in a CRM. Every customer-facing message your team sends should show up on the contact’s timeline in the CRM too — in addition to the email, not instead of it.

When it fires

  1. An admin posts a customer note in the Customer Notes tab, or a customer replies from their portal, or a script POSTs to /updates/{id}/customer-notes.
  2. The plugin saves the note and queues an Action Scheduler job to email the customer.
  3. This hook fires — immediately, not after the email sends.

What you get

ArgumentTypeWhat it is
$note_idintThe new note’s ID.
$update_idintThe update it’s attached to.
$notestringThe note body, post-sanitization.
$requestWP_REST_RequestThe originating request.

The code

add_action( 'order_updates_for_woo_after_add_customer_note', 'my_addon_mirror_to_crm', 10, 4 );

function my_addon_mirror_to_crm( int $note_id, int $update_id, string $note, WP_REST_Request $request ): void {
    $order = wc_get_order( (int) $request->get_param( 'order_id' ) );
    if ( ! $order ) {
        return;
    }

    My_Addon\Crm::add_engagement( [
        'contact_email' => $order->get_billing_email(),
        'order_number'  => $order->get_order_number(),
        'direction'     => 'outbound',
        'body'          => $note,
        'sent_at'       => current_time( 'mysql', true ),
    ] );
}

Things worth knowing

This fires for both directions. Staff-to-customer messages and customer-to-staff replies both hit this hook. If you only want one, branch on the current user being a customer (no edit_shop_order capability).

You’re inside the REST request. If the CRM is slow, the admin saving the note sees the spinner. If that matters — usually it does — queue your work via AsyncJob::queue() or schedule it directly with Action Scheduler.

If you need the order context. Customer-side requests pass order_id as a parameter; admin-side requests don’t (the order is inferred from the update). The robust path is to load the update via OrderUpdatesDb::get_update() and pull order_id from there.

Run code when a customer rates an update

order_updates_for_woo_after_customer_rating

A 1-, 2-, or 3-star rating is your early-warning system for an unhappy customer. The plugin already sends an admin alert email, but you want richer routing — a Zap that opens a ticket in your helpdesk, pings #escalations, and assigns it to a senior rep.

When it fires

  1. A customer submits a star rating (1–5) from the portal’s rating widget.
  2. The plugin saves the rating row, kicks off the follow-up email (promoter share or detractor empathy), and if it’s a low rating, queues the admin alert email.
  3. This hook fires.

What you get

ArgumentTypeWhat it is
$update_idintThe rated update.
$order_idintThe order it’s on.
$ratingarraystars, comment, created_by_name, created_at.
$requestWP_REST_RequestThe originating request.

The code

add_action( 'order_updates_for_woo_after_customer_rating', 'my_addon_zap_on_detractor', 10, 4 );

function my_addon_zap_on_detractor( int $update_id, int $order_id, array $rating, WP_REST_Request $request ): void {
    if ( (int) $rating['stars'] > 3 ) {
        return; // Promoters and neutrals don't need paging.
    }

    $order = wc_get_order( $order_id );

    wp_remote_post( MY_ADDON_ZAPIER_HOOK_URL, [
        'headers' => [ 'Content-Type' => 'application/json' ],
        'body'    => wp_json_encode( [
            'stars'        => (int) $rating['stars'],
            'comment'      => (string) $rating['comment'],
            'update_id'    => $update_id,
            'order_number' => $order ? $order->get_order_number() : (string) $order_id,
            'customer'     => $order ? $order->get_billing_email() : '',
            'admin_url'    => admin_url( 'admin.php?page=wc-orders&action=edit&id=' . $order_id ),
        ] ),
        'timeout' => 5,
    ] );
}

Things worth knowing

3 stars is a soft line. The plugin treats 1–3 as detractors and 4–5 as promoters. Some teams move that line to 2/3 (treating 3 as “OK” rather than “bad”) — your call. Just be consistent inside your handler.

wp_remote_post with a tight timeout. The customer is waiting on the response to their rating submission. A 30-second hang on Zapier’s end means a 30-second spinner on the rating page. Keep the timeout low; if Zapier is down, fail silently and rely on the plugin’s built-in admin email as the backstop.

Add custom fields to a REST response

order_updates_for_woo_save_update_response

You’ve added a custom button to the update card (see “UI insertion points” below). When the admin clicks it, your JS needs a nonce to call your own REST endpoint. The cleanest place to mint that nonce is when the update is saved — right when your JS already has fresh data in hand.

When it fires

Right before POST /updates returns its JSON response. Same shape as every other *_response filter on this plugin’s endpoints — learn the pattern once, use it everywhere.

What you get

ArgumentTypeWhat it is
$responsearrayThe response array. Default keys: cardHtml, updateId, isEdit, message, noteId.
$requestWP_REST_RequestThe request that produced this response.

The code

add_filter( 'order_updates_for_woo_save_update_response', 'my_addon_attach_action_nonce', 10, 2 );

function my_addon_attach_action_nonce( array $response, WP_REST_Request $request ): array {
    $update_id = (int) ( $response['updateId'] ?? 0 );
    if ( ! $update_id ) {
        return $response;
    }

    $response['my_addon'] = [
        'sync_endpoint' => rest_url( 'my-addon/v1/updates/' . $update_id . '/sync' ),
        'nonce'         => wp_create_nonce( 'my_addon_sync_' . $update_id ),
    ];

    return $response;
}

Things worth knowing

The same pattern works for every endpoint. order_updates_for_woo_delete_update_response, order_updates_for_woo_mark_solved_response, order_updates_for_woo_poll_customer_thread_response — same two arguments, same return convention. Pick the one your JS will see and append your data there.

Don’t clobber existing keys. Always namespace under your own root key (my_addon in the example) so a future plugin version can add new top-level fields without colliding with yours.

Register or alter a settings tab

order_updates_for_woo_settings_section_controllers

Your addon has its own settings — API keys for the CRM, a list of channels to mirror to, on/off toggles. You could put them in their own top-level WP admin page, but the natural home is alongside the plugin’s built-in tabs. One settings screen, one mental model.

When it fires

Once per page load, when the plugin builds its list of settings tabs. You append your controller; the plugin renders your tab in the same row as General, Customer, Members, etc.

What you get

ArgumentTypeWhat it is
$controllersarrayExisting tab controller objects. Each implements the same small shape: slug, label, register(), render().

The code

add_filter( 'order_updates_for_woo_settings_section_controllers', 'my_addon_register_settings_tab' );

function my_addon_register_settings_tab( array $controllers ): array {
    $controllers[] = new My_Addon\Settings\IntegrationTabController();
    return $controllers;
}

// my-addon/src/Settings/IntegrationTabController.php
namespace My_Addon\Settings;

class IntegrationTabController {

    public function slug(): string {
        return 'my-addon-integration';
    }

    public function label(): string {
        return __( 'CRM integration', 'my-addon' );
    }

    public function register(): void {
        register_setting( 'my_addon_integration', 'my_addon_crm_api_key', 'sanitize_text_field' );
    }

    public function render(): void {
        settings_fields( 'my_addon_integration' );
        // ... your <input> fields here ...
        submit_button();
    }
}

Things worth knowing

Copy a built-in controller’s shape. Open src/Admin/Settings/Controllers/GeneralTabController.php in the plugin source. Your controller needs the same four methods (slug, label, register, render). Copy-paste-rename is the fastest start.

Use WP’s settings API, not your own. register_setting() + settings_fields() + add_settings_field() have been tested for years across millions of sites. Don’t roll your own form handling — you’ll just re-invent every input edge case the WP team already solved.

Add a custom button to the update card

order_updates_for_woo_update_card_actions

You want a one-click action on each update — “Sync to CRM now”, “Open in Linear”, “Mark as escalated”. The card’s action bar is the right home. Sits alongside Mark Solved and Delete, looks native, no extra navigation.

When it fires

Every time the plugin renders an update card — on first page load and after every save/edit. Your handler echoes markup into the action bar; the plugin doesn’t care what you put there.

What you get

ArgumentTypeWhat it is
$updatearrayThe full update record being rendered.

The code

add_action( 'order_updates_for_woo_update_card_actions', 'my_addon_render_sync_button' );

function my_addon_render_sync_button( array $update ): void {
    if ( ! current_user_can( 'manage_woocommerce' ) ) {
        return;
    }

    $update_id = (int) $update['id'];

    printf(
        '<button type="button" class="button awts_card__action" data-my-addon-sync="%1$d" data-nonce="%2$s">%3$s</button>',
        $update_id,
        esc_attr( wp_create_nonce( 'my_addon_sync_' . $update_id ) ),
        esc_html__( 'Sync to CRM', 'my-addon' )
    );
}

Things worth knowing

Capability-check before rendering. The plugin already gates the card itself behind edit_shop_order, but your action might need stricter rules. Bailing inside the handler is harmless — the bar just doesn’t render your button for users who can’t use it.

Use the plugin’s CSS classes if you want native styling. awts_card__action matches the look of Mark Solved / Delete. If you want to stand out (a red “Escalate” button, say), add your own class on top.

Don’t inline the click handler. The example uses a data- attribute the JS picks up via event delegation. That keeps your handler out of every rendered card, plays nicely with the plugin’s own JS, and tests cleanly.

Add your own tab to the update card

order_updates_for_woo_update_card_tabs

You’re building a deeper integration — maybe a panel showing the CRM contact history for this order, or a list of Linear tickets linked to the update. A new tab next to Internal Notes / Customer Notes is the right home; you get your own panel without crowding the existing ones.

When it fires

Each time the card’s tab strip renders. You echo a <li> with a data-tab attribute; the plugin’s JS handles the click-to-switch behaviour. You separately mount your panel content via your own hook (typically into order_updates_for_woo_update_card_after_details) and toggle it based on the data-tab value.

What you get

ArgumentTypeWhat it is
$updatearrayThe full update record being rendered.

The code

add_action( 'order_updates_for_woo_update_card_tabs',         'my_addon_render_crm_tab_button' );
add_action( 'order_updates_for_woo_update_card_after_details', 'my_addon_render_crm_tab_panel' );

function my_addon_render_crm_tab_button( array $update ): void {
    printf(
        '<li class="awts_card__tab" data-tab="my-addon-crm">%s</li>',
        esc_html__( 'CRM activity', 'my-addon' )
    );
}

function my_addon_render_crm_tab_panel( array $update ): void {
    $update_id = (int) $update['id'];

    printf(
        '<div class="awts_card__panel" data-tab-panel="my-addon-crm" hidden>'
        . '  <p>%1$s</p>'
        . '  <ul class="my-addon-crm-list" data-update="%2$d"></ul>'
        . '</div>',
        esc_html__( 'Loading CRM activity...', 'my-addon' ),
        $update_id
    );
}

Then in your addon’s JS, listen for tab switches and fetch the panel data from your own REST endpoint the first time it’s shown.

Things worth knowing

Match the data-tab values exactly. The button’s data-tab="my-addon-crm" has to match the panel’s data-tab-panel="my-addon-crm". Typos here mean the click happens but nothing shows.

Lazy-load your panel content. Don’t render the full CRM history server-side on every card — if the panel never gets opened, that’s a wasted DB hit. Render an empty container, then fetch the actual data via REST when the tab is first clicked.

Replace 30-second polling with WebSockets (Pusher, Ably, etc.)

order_updates_for_woo_realtime_config

The plugin uses a 30-second poll for v1.0 — good enough for most stores but visibly laggy on busy ones. If you’re comfortable wiring up Pusher (or Ably, or your own WebSocket), you can replace the poll entirely while keeping it as a fallback.

When it fires

Once per customer-portal page load. The plugin’s JS reads the returned config and decides which realtime driver to use. The default config is empty — the JS falls back to polling. Populate the config from this filter and the JS uses your driver instead.

What you get

ArgumentTypeWhat it is
$configarrayEmpty by default. Whatever you return ends up in window.AWTS_COU_CONFIG.realtimeConfig.
$order_idintThe order whose thread is being rendered. Use this to scope your WebSocket channel.

The code

// Server side -- inject the Pusher config the JS will read.
add_filter( 'order_updates_for_woo_realtime_config', 'my_addon_pusher_config', 10, 2 );

function my_addon_pusher_config( array $config, int $order_id ): array {
    return array_merge( $config, [
        'driver'  => 'pusher',
        'app_key' => getenv( 'PUSHER_APP_KEY' ),
        'cluster' => 'eu',
        'channel' => "order-{$order_id}",
    ] );
}

// Server side -- publish to the channel when a customer note is added.
add_action( 'order_updates_for_woo_after_add_customer_note', 'my_addon_publish_to_pusher', 10, 4 );

function my_addon_publish_to_pusher( int $note_id, int $update_id, string $note, WP_REST_Request $request ): void {
    $update = OrderUpdatesForWoo\Plugin::db()->get_update( $update_id );
    if ( ! $update ) {
        return;
    }

    My_Addon\Pusher::publish( "order-{$update['order_id']}", 'customer-note.added', [
        'note_id'   => $note_id,
        'update_id' => $update_id,
    ] );
}

Then enqueue your driver JS that subscribes to the Pusher channel and calls the customer page’s realtime callbacks (appendNote, updateNote, reloadPage). Those callbacks are exposed on window.AWTS_COU_DRIVER_CALLBACKS for exactly this purpose.

Things worth knowing

Polling stays as a fallback. If your WebSocket connection drops mid-page (subway tunnel, dodgy hotel wifi), the JS notices and resumes the 30s poll automatically. The customer doesn’t see broken realtime — they see a slightly slower one.

Public Pusher app keys are public on purpose. Don’t freak out that you’re leaking the app key into the customer’s page source — Pusher app keys are designed to be public. Your server’s app secret is what matters; never inject that into the config.

Test the failure path. Most addons that swap realtime work great on the happy path and silently die when the WebSocket can’t connect. Disable your network mid-session and confirm polling actually kicks back in — that’s the harder test.

Everything else

The hooks covered above are the ones that come up most often. The rest — before_* partners for every after_* shown here, response filters per endpoint, attachment-storage hooks, label/string filters, customer-portal injection points, all 50+ of them — live in Reference → All hooks with their signatures.

If you’re trying to do something and can’t find a hook for it, that’s a bug. Open an issue — missing hooks get added.