Integrations when a form doesn't * set its own. */ class Webhook_Integration extends Abstract_Integration { /** Set just before the fire-and-forget request so the http_api_debug callback (which fires for every wp_remote_* call site-wide) knows which form this particular response belongs to. */ private static $pending_form_id = null; /** The retry attempt log_response() should schedule the NEXT one from, if this one fails — see the class doc comment for why this can't just be inferred from Api_Log. */ private static $pending_context = null; const RETRY_HOOK = 'chargeforms_retry_webhook'; const MAX_ATTEMPTS = 3; const RETRY_DELAY_SECONDS = 300; public static function boot() { add_action( self::RETRY_HOOK, [ __CLASS__, 'handle_retry' ], 10, 5 ); } public function key(): string { return 'webhook'; } public function label(): string { return __( 'Webhook (Zapier, Make, custom URL)', 'chargeforms' ); } public function is_configured( array $settings ): bool { return ! empty( $this->resolve_url( $settings ) ); } /** * `$attempt` (1-based) is new but optional — Abstract_Integration's * process() signature is otherwise unchanged, and Webhook_Integration * is currently the only concrete integration, so this can't break any * other implementer. roadmap issue #33 ("Notifications and Webhooks * Lack Strong Observability" — "Failure handling is partial and not * tied to a retryable job model"): a failed delivery previously just * sat in Api_Log with nothing to act on it. Since this request is * fire-and-forget ('blocking' => false), the actual success/failure * is only known later, inside log_response() below — that's where the * retry decision has to be made, not here. */ public function process( array $values, array $form, int $entry_id, array $settings, $attempt = 1 ) { $url = $this->resolve_url( $settings ); self::$pending_form_id = $form['id']; self::$pending_context = [ 'values' => $values, 'form' => $form, 'entry_id' => $entry_id, 'settings' => $settings, 'attempt' => $attempt, 'url' => $url, ]; add_action( 'http_api_debug', [ __CLASS__, 'log_response' ], 10, 5 ); wp_remote_post( $url, [ 'timeout' => 8, 'blocking' => false, 'headers' => [ 'Content-Type' => 'application/json' ], 'body' => wp_json_encode( [ 'form_id' => $form['id'], 'entry_id' => $entry_id, 'values' => $values, ] ), ] ); remove_action( 'http_api_debug', [ __CLASS__, 'log_response' ], 10 ); self::$pending_form_id = null; self::$pending_context = null; } /** WP-Cron callback — re-attempts a delivery that previously failed. */ public static function handle_retry( $values, $form, $entry_id, $settings, $attempt ) { ( new self() )->process( $values, $form, $entry_id, $settings, $attempt ); } /** * Fires for every outgoing WP HTTP request, blocking or not — 'blocking' * only controls whether the calling code waits for the return value, the * request itself still completes and this hook still reports it. Logs * whatever WordPress actually observed: a real status code when the * transport got one, or the WP_Error otherwise (e.g. the endpoint timed * out) — never a fabricated "success". A failure schedules a retry * (roadmap issue #33) unless MAX_ATTEMPTS has already been reached. */ public static function log_response( $response, $context, $class, $args, $url ) { if ( 'response' !== $context || null === self::$pending_form_id ) { return; } $failed = false; if ( is_wp_error( $response ) ) { $message = $response->get_error_message() ?: __( 'The request failed with no further detail (this happens for a fire-and-forget request whose connection never completed).', 'chargeforms' ); Api_Log::log( 'webhook', self::$pending_form_id, $url, null, $message ); $failed = true; } else { $code = wp_remote_retrieve_response_code( $response ); $body = wp_remote_retrieve_body( $response ); if ( ! $code && ! $body ) { // The non-blocking transport sometimes hands back an empty // response object rather than a WP_Error — still an honest // "nothing came back", just phrased for a human reading the log. $body = __( 'No response received (this happens for a fire-and-forget request whose connection never completed).', 'chargeforms' ); } Api_Log::log( 'webhook', self::$pending_form_id, $url, $code ?: null, $body ); $failed = ! $code || $code >= 400; } if ( $failed && self::$pending_context && self::$pending_context['attempt'] < self::MAX_ATTEMPTS ) { $ctx = self::$pending_context; wp_schedule_single_event( time() + self::RETRY_DELAY_SECONDS, self::RETRY_HOOK, [ $ctx['values'], $ctx['form'], $ctx['entry_id'], $ctx['settings'], $ctx['attempt'] + 1 ] ); } } private function resolve_url( array $settings ) { $url = $settings['url'] ?? Settings::get( 'webhook_default_url' ); return ( $url && wp_http_validate_url( $url ) ) ? $url : ''; } }