get_header( 'x_signature' ) ); $timestamp = sanitize_text_field( (string) $request->get_header( 'x_timestamp' ) ); $raw_body = (string) $request->get_body(); if ( strlen( $raw_body ) > self::MAX_BODY_BYTES ) { return $this->reply( 413, 'Body too large' ); } if ( '' === $signature ) { return $this->reply( 400, 'Missing signature header' ); } if ( '' === $timestamp || 1 !== preg_match( '/^-?\d{1,12}$/', $timestamp ) || 0 === (int) $timestamp ) { return $this->reply( 400, 'Missing or invalid timestamp header' ); } if ( '' === $raw_body ) { return $this->reply( 400, 'Empty body' ); } $timestamp = (int) $timestamp; // Without a freshness check an intercepted "executed" webhook stays valid forever. $skew = time() - $timestamp; if ( abs( $skew ) > Signature::WEBHOOK_TOLERANCE ) { $this->log_once( 'stale', sprintf( 'Webhook rejected: time skew %+d s over a %d s tolerance.', $skew, Signature::WEBHOOK_TOLERANCE ) ); return $this->reply( 400, 'Stale timestamp' ); } // Decoded to stdClass: the canonical string depends on the object/list distinction. $decoded = json_decode( $raw_body ); if ( null === $decoded && JSON_ERROR_NONE !== json_last_error() ) { return $this->reply( 400, 'Invalid JSON' ); } // No callback key yet: fetch it (at most every five minutes) and answer 503 so // Cryptoway re-delivers; a 401 would reject a notification that is only early. $callback_key = CallbackKey::for_webhook(); if ( '' === $callback_key ) { // The endpoint is public and nothing is authenticated yet: one line a minute, not one per request. $this->log_once( 'nokey', 'Webhook deferred: no callback key has been received from Cryptoway yet — replying 503 for re-delivery.' ); return $this->reply( 503, 'Callback key not received yet, please retry' ); } $verified = $this->verify( $signature, $timestamp, $decoded, $callback_key ); if ( null === $verified ) { $this->log_once( 'bad', 'Webhook rejected: signature mismatch. Body: ' . $this->shorten( $raw_body ) ); return $this->reply( 401, 'Invalid signature' ); } $payload = $this->extract_payload( $decoded ); if ( ! is_array( $payload ) ) { return $this->reply( 400, 'Unexpected payload' ); } $type = (string) ( $payload['type'] ?? '' ); return match ( $type ) { 'invoice' => $this->process_invoice( $payload, $signature, $verified ), 'deposit' => $this->process_deposit( $payload, $signature, $verified ), default => $this->reply( 200, 'Ignored: unknown type' ), }; } /** * Verify the signature. The docs show the invoice webhook in a {"params","body"} * envelope and the deposit webhook flat, so both forms are accepted with the same key. * * @return string|null Name of the form that matched, or null. */ private function verify( string $signature, int $timestamp, mixed $decoded, string $key ): ?string { if ( Signature::verify( $signature, $timestamp, $decoded, $key ) ) { return 'body as received'; } $is_wrapped = $decoded instanceof \stdClass && property_exists( $decoded, 'params' ) && property_exists( $decoded, 'body' ); if ( ! $is_wrapped ) { $wrapped = new \stdClass(); $wrapped->params = ''; $wrapped->body = $decoded; if ( Signature::verify( $signature, $timestamp, $wrapped, $key ) ) { return 'body in a params/body envelope'; } } elseif ( Signature::verify( $signature, $timestamp, $decoded->body, $key ) ) { return 'nested body only'; } return null; } /** The payload from the envelope or from a flat body. */ private function extract_payload( mixed $decoded ): ?array { if ( $decoded instanceof \stdClass && property_exists( $decoded, 'params' ) && property_exists( $decoded, 'body' ) ) { $decoded = $decoded->body; } $array = json_decode( wp_json_encode( $decoded ), true ); return is_array( $array ) ? $array : null; } private function process_invoice( array $payload, string $signature, string $form ): \WP_REST_Response { $invoice_id = (string) ( $payload['id'] ?? '' ); $invoice_status = (string) ( $payload['status'] ?? '' ); if ( '' === $invoice_id || '' === $invoice_status ) { return $this->reply( 400, 'Missing invoice id or status' ); } try { $payment = $this->find_payment( $invoice_id, (string) ( $payload['external_id'] ?? '' ) ); } catch ( \RuntimeException $e ) { Log::write( sprintf( 'Webhook: payment lookup for invoice %s failed (%s) — replying 503 for re-delivery.', $invoice_id, $e->getMessage() ) ); return $this->reply( 503, 'Payment lookup failed, please retry' ); } if ( ! $payment ) { return $this->reply( 404, 'Unknown invoice' ); } $manager = new PaymentManager( $payment ); $fingerprint = md5( 'invoice|' . $invoice_id . '|' . $invoice_status . '|' . $signature ); if ( $manager->is_duplicate_event( $fingerprint ) ) { return $this->reply( 200, 'Already processed' ); } if ( $payment->invoice_id() !== $invoice_id ) { // A payment has one invoice; a notification naming another one is not applied to it. return $this->reply( 200, 'Ignored: not the current invoice' ); } $this->remember_form( $form, $payment ); // The invoice is read from the API for executed, expired and cancelled: the body has // neither the amount nor the coins on it. After the deadline a notification may // still say active or pending; expired appears slightly later. $closing = in_array( $invoice_status, [ 'expired', 'cancelled' ], true ); $recheck = 'executed' === $invoice_status || $closing || ( in_array( $invoice_status, [ 'active', 'pending' ], true ) && $payment->deadline_has_passed() ); $invoice = null; if ( $recheck ) { $fetched = ApiClient::from_settings()->get_invoice( $invoice_id ); // An answer without a status (a proxy's empty 200, an error page) is no invoice either. $readable = ! is_wp_error( $fetched ) && ! empty( $fetched['status'] ); if ( $readable ) { $invoice = $fetched; // The API record outranks the body. $invoice_status = (string) $fetched['status']; } elseif ( $closing ) { // Coins may be on the invoice; do not close on the claim alone. return $this->reply( 503, 'Could not check the invoice with Cryptoway, please retry' ); } elseif ( is_wp_error( $fetched ) ) { // Only lets a hold note say why the payment could not be checked. $manager->explain_read_failure( $fetched ); } } if ( ! $manager->apply_invoice_status( $invoice_status, $invoice, 'webhook' ) ) { return $this->busy( $payment ); } // Recorded only after the work succeeded, so a failed run is retried. $manager->remember_event( $fingerprint ); return $this->reply( 200, 'ok' ); } /** * Another notification for the same invoice is being applied right now: nothing was done with * this one, so Cryptoway is asked to deliver it again instead of being told it was taken. */ private function busy( Payment $payment ): \WP_REST_Response { $this->log_once( 'busy', sprintf( 'Webhook for payment #%d arrived while another one for the same invoice was being applied — replying 503 for re-delivery.', $payment->id() ) ); return $this->reply( 503, 'Another notification for this invoice is being processed, please retry' ); } private function process_deposit( array $payload, string $signature, string $form ): \WP_REST_Response { $invoice_id = (string) ( $payload['invoice']['id'] ?? '' ); $external_id = (string) ( $payload['external_id'] ?? '' ); $deposit_status = (string) ( $payload['status'] ?? '' ); try { $payment = $this->find_payment( $invoice_id, $external_id ); } catch ( \RuntimeException $e ) { Log::write( sprintf( 'Webhook: payment lookup for a deposit on invoice %s failed (%s) — replying 503 for re-delivery.', $invoice_id, $e->getMessage() ) ); return $this->reply( 503, 'Payment lookup failed, please retry' ); } if ( ! $payment ) { if ( '' === $invoice_id && '' === $external_id ) { // Not about a button payment, e.g. a transfer to a permanent address. return $this->reply( 200, 'Ignored: no invoice or external id' ); } return $this->reply( 404, 'Unknown invoice' ); } $manager = new PaymentManager( $payment ); $fingerprint = md5( 'deposit|' . $deposit_status . '|' . $signature ); if ( '' !== $invoice_id && $invoice_id !== $payment->invoice_id() ) { return $this->reply( 200, 'Ignored: not the current invoice' ); } $this->remember_form( $form, $payment ); $manager->save_deposit_data( $payload ); if ( $manager->is_duplicate_event( $fingerprint ) ) { return $this->reply( 200, 'Already processed' ); } $closed = in_array( $payment->invoice_status(), [ 'expired', 'cancelled' ], true ); switch ( $deposit_status ) { case 'executed': // A deposit is one transfer, not a closed invoice: a partial payment is also // "executed". The payment follows the invoice's state, read from the API. $invoice = '' !== $payment->invoice_id() ? ApiClient::from_settings()->get_invoice( $payment->invoice_id() ) : null; $invoice_status = ( is_array( $invoice ) && ! empty( $invoice['status'] ) ) ? (string) $invoice['status'] : ''; if ( '' === $invoice_status ) { // Lookup failed: record the money without confirming; the scheduled check returns to it. $manager->note_deposit_unconfirmed( is_wp_error( $invoice ) ? $invoice : null ); break; } if ( ! $manager->apply_invoice_status( $invoice_status, is_array( $invoice ) ? $invoice : null, 'webhook' ) ) { return $this->busy( $payment ); } if ( 'executed' !== $invoice_status ) { // Coins that already cover the invoice mean only the gap before Cryptoway // closes it, not an underpayment. On a closed invoice the status path above // has already said what matters. if ( in_array( $invoice_status, [ 'expired', 'cancelled' ], true ) ) { break; } if ( $manager->invoice_is_covered( is_array( $invoice ) ? $invoice : null ) ) { $manager->note_awaiting_confirmation(); } else { $manager->note_partial_payment( is_array( $invoice ) ? $invoice : null ); } } break; case 'pending': // Money on an invoice that already closed is a late payment, not "confirming". if ( $closed ) { $manager->hold_late_deposit( $payload ); break; } if ( ! $manager->apply_invoice_status( 'pending', null, 'webhook' ) ) { return $this->busy( $payment ); } break; case 'dirty_lock': if ( ! $manager->apply_invoice_status( 'dirty_lock', null, 'webhook' ) ) { return $this->busy( $payment ); } break; case 'cancelled': $payment->add_history( __( 'Cryptoway: the incoming payment was cancelled.', 'cryptoway-button' ) ); break; } // Recorded only after the work succeeded; see remember_event(). $manager->remember_event( $fingerprint ); return $this->reply( 200, 'ok' ); } /** * The payment a notification is about: by invoice id, then by external_id. * * @throws \RuntimeException When the database could not be asked. */ private function find_payment( string $invoice_id, string $external_id ): ?Payment { $payment = '' !== $invoice_id ? Payment::find_by_invoice_id( $invoice_id ) : null; if ( ! $payment && '' !== $external_id ) { $payment = Payment::find_by_external_id( $external_id ); } return $payment; } /** Which signature form the live Cryptoway uses is recorded once: support needs to know. */ private function remember_form( string $form, Payment $payment ): void { if ( '' === (string) get_option( 'cryptoway_button_webhook_form', '' ) ) { update_option( 'cryptoway_button_webhook_form', $form, false ); Log::write( sprintf( 'Webhook accepted (canonicalisation: %s) for payment #%d.', $form, $payment->id() ) ); } } private function reply( int $status, string $message ): \WP_REST_Response { $key = $status >= 400 ? 'error' : 'status'; return new \WP_REST_Response( [ $key => $message ], $status ); } /** Log a rejected webhook at most once a minute per reason: the endpoint is public. */ private function log_once( string $reason, string $message ): void { $gate = 'cryptoway_button_webhook_' . $reason; if ( false !== get_transient( $gate ) ) { return; } set_transient( $gate, 1, MINUTE_IN_SECONDS ); Log::write( $message . ' (further rejections are not logged for a minute)' ); } private function shorten( string $raw_body ): string { $raw_body = ApiClient::mask_secrets( $raw_body ); return strlen( $raw_body ) > 300 ? substr( $raw_body, 0, 300 ) . sprintf( '… (truncated, %d bytes total)', strlen( $raw_body ) ) : $raw_body; } }