| Code Coverage
| Lines | Functions and Methods | Classes and Traits
Total | 51.74% covered (warning) | 51.74% | 89 / 172 | 23.08% covered (danger) | 23.08% | 6 / 26 | CRAP | 0.00% covered (danger) | 0.00% | 0 / 1
RestClient | 51.74% covered (warning) | 51.74% | 89 / 172 | 23.08% covered (danger) | 23.08% | 6 / 26 | 355.85 | 0.00% covered (danger) | 0.00% | 0 / 1
fromOption | 100.00% covered (success) | 100.00% | 2 / 2 | 100.00% covered (success) | 100.00% | 1 / 1 | 1 |
__construct | 100.00% covered (success) | 100.00% | 2 / 2 | 100.00% covered (success) | 100.00% | 1 / 1 | 1 |
get | 100.00% covered (success) | 100.00% | 3 / 3 | 100.00% covered (success) | 100.00% | 1 / 1 | 3 |
post | 100.00% covered (success) | 100.00% | 1 / 1 | 100.00% covered (success) | 100.00% | 1 / 1 | 1 |
delete | 100.00% covered (success) | 100.00% | 1 / 1 | 100.00% covered (success) | 100.00% | 1 / 1 | 1 |
registerIndexationPage | 0.00% covered (danger) | 0.00% | 0 / 1 | 0.00% covered (danger) | 0.00% | 0 / 1 | 2 |
getIndexationPageStatus | 0.00% covered (danger) | 0.00% | 0 / 1 | 0.00% covered (danger) | 0.00% | 0 / 1 | 2 |
lastError | 100.00% covered (success) | 100.00% | 1 / 1 | 100.00% covered (success) | 100.00% | 1 / 1 | 1 |
clearError | 0.00% covered (danger) | 0.00% | 0 / 1 | 0.00% covered (danger) | 0.00% | 0 / 1 | 2 |
fail | 92.31% covered (success) | 92.31% | 12 / 13 | 0.00% covered (danger) | 0.00% | 0 / 1 | 3.00 |
request | 79.73% covered (warning) | 79.73% | 59 / 74 | 0.00% covered (danger) | 0.00% | 0 / 1 | 12.01 |
earnAdOnImpression | 0.00% covered (danger) | 0.00% | 0 / 6 | 0.00% covered (danger) | 0.00% | 0 / 1 | 2 |
earnAdOnClick | 0.00% covered (danger) | 0.00% | 0 / 6 | 0.00% covered (danger) | 0.00% | 0 / 1 | 2 |
spendAdOnImpression | 0.00% covered (danger) | 0.00% | 0 / 5 | 0.00% covered (danger) | 0.00% | 0 / 1 | 2 |
spendAdOnClick | 0.00% covered (danger) | 0.00% | 0 / 5 | 0.00% covered (danger) | 0.00% | 0 / 1 | 2 |
listBacklinks | 0.00% covered (danger) | 0.00% | 0 / 4 | 0.00% covered (danger) | 0.00% | 0 / 1 | 6 |
listSourceChecks | 0.00% covered (danger) | 0.00% | 0 / 4 | 0.00% covered (danger) | 0.00% | 0 / 1 | 6 |
recheckSource | 0.00% covered (danger) | 0.00% | 0 / 6 | 0.00% covered (danger) | 0.00% | 0 / 1 | 12 |
recheckAllSources | 0.00% covered (danger) | 0.00% | 0 / 1 | 0.00% covered (danger) | 0.00% | 0 / 1 | 2 |
listAuditLog | 0.00% covered (danger) | 0.00% | 0 / 8 | 0.00% covered (danger) | 0.00% | 0 / 1 | 12 |
listBacklinkApprovals | 88.89% covered (warning) | 88.89% | 8 / 9 | 0.00% covered (danger) | 0.00% | 0 / 1 | 4.02 |
rejectBacklink | 0.00% covered (danger) | 0.00% | 0 / 4 | 0.00% covered (danger) | 0.00% | 0 / 1 | 6 |
rejectBacklinkBulk | 0.00% covered (danger) | 0.00% | 0 / 6 | 0.00% covered (danger) | 0.00% | 0 / 1 | 12 |
updateBacklink | 0.00% covered (danger) | 0.00% | 0 / 4 | 0.00% covered (danger) | 0.00% | 0 / 1 | 2 |
deleteBacklink | 0.00% covered (danger) | 0.00% | 0 / 3 | 0.00% covered (danger) | 0.00% | 0 / 1 | 2 |
creditsBalance | 0.00% covered (danger) | 0.00% | 0 / 1 | 0.00% covered (danger) | 0.00% | 0 / 1 | 2 |

1 | <?php
2 | /**
3 | * REST Client (client → server).
4 | *
5 | * Wraps wp_remote_request with HMAC signing + JSON handling.
6 | * Uses SwapAds\Shared\Http\RequestBuilder to construct signed headers.
7 | *
8 | * @package SwapAds\Client\Api
9 | * @since 1.0.0
10 | */
11 |
12 | declare(strict_types=1);
13 |
14 | namespace SwapAds\Client\Api;
15 |
16 | use SwapAds\Client\License\FreemiusAutoActivator;
17 | use SwapAds\Client\License\LicenseManager;
18 | use SwapAds\Client\Security\EncryptedOption;
19 | use SwapAds\Shared\Http\RequestBuilder;
20 |
21 | /**
22 | * Class RestClient.
23 | *
24 | * @since 1.0.0
25 | */
26 | final class RestClient
27 | {
28 | /**
29 | * REST namespace as registered on the server side.
30 | * The base URL is the WP site root (e.g. https://api.swapads.eu);
31 | * the actual REST endpoints live at {base}/wp-json/{namespace}.
32 | */
33 | public const NAMESPACE = 'swapads-server';
34 |
35 | /**
36 | * Public REST root = base + /wp-json/{namespace}.
37 | */
38 | public const REST_ROOT_TEMPLATE = '%s/wp-json/%s';
39 |
40 | private string $baseUrl;
41 | private int $timeout;
42 |
43 | /**
44 | * Construct a RestClient using the configured server URL option.
45 | *
46 | * @return self
47 | */
48 | public static function fromOption(): self
49 | {
50 | $url = (string) get_option('swapads_client_server_url', 'https://api.swapads.eu');
51 | return new self($url);
52 | }
53 |
54 | /**
55 | * @param string $baseUrl Server root (e.g. https://api.swapads.eu)
56 | * @param int $timeout Request timeout in seconds.
57 | */
58 | public function __construct(string $baseUrl, int $timeout = 10)
59 | {
60 | $this->baseUrl = rtrim($baseUrl, '/');
61 | $this->timeout = $timeout;
62 | }
63 |
64 | /**
65 | * GET request with HMAC auth.
66 | *
67 | * @param string $route Route relative to namespace (no leading slash).
68 | * @param array $params Optional query params.
69 | *
70 | * @return array<string, mixed> Decoded response body.
71 | */
72 | public function get(string $route, array $params = []): array
73 | {
74 | if (!empty($params)) {
75 | $route .= (strpos($route, '?') === false ? '?' : '&') . http_build_query($params);
76 | }
77 | return $this->request('GET', $route, '');
78 | }
79 |
80 | /**
81 | * POST request with HMAC auth + JSON body.
82 | *
83 | * @param string $route
84 | * @param array $body
85 | *
86 | * @return array<string, mixed>
87 | */
88 | public function post(string $route, array $body): array
89 | {
90 | return $this->request('POST', $route, (string) json_encode($body));
91 | }
92 |
93 | /**
94 | * Send a signed DELETE request.
95 | *
96 | * Used by MVP-005 (2026-08-01) for the `Plugin::onDeactivatedPlugin` flow.
97 | *
98 | * @param string $route Path relative to base URL.
99 | *
100 | * @return array<string, mixed> Decoded JSON response.
101 | *
102 | * @since 1.5.7
103 | */
104 | public function delete(string $route): array
105 | {
106 | return $this->request('DELETE', $route, '');
107 | }
108 |
109 | /**
110 | * Register an indexation page URL with the server (MVP-001, 2026-08-01).
111 | *
112 | * The URL is stored server-side and HEAD-checked daily via
113 | * IndexationVerifierCron.
114 | *
115 | * @param string $url Indexation page URL.
116 | *
117 | * @return array<string, mixed> Server response.
118 | *
119 | * @since 1.5.7
120 | */
121 | public function registerIndexationPage(string $url): array
122 | {
123 | return $this->post('/indexation-page/register', ['url' => $url]);
124 | }
125 |
126 | /**
127 | * Fetch the current indexation page verification status from the server.
128 | *
129 | * @return array<string, mixed> Server response.
130 | *
131 | * @since 1.5.7
132 | */
133 | public function getIndexationPageStatus(): array
134 | {
135 | return $this->get('/indexation-page/status');
136 | }
137 |
138 | /**
139 | * Make a signed request to the server.
140 | *
141 | * @param string $method
142 | * @param string $route Path relative to base URL.
143 | * @param string $body Raw JSON string (POST) or empty (GET).
144 | *
145 | * @return array<string, mixed> Decoded JSON response.
146 | *
147 | * @throws \RuntimeException on transport errors.
148 | */
149 | /**
150 | * Last error encountered by any REST call on this instance.
151 | * Shape: {error_code, message, http_status, raw?, request_route?, when}
152 | * Null when the last call succeeded.
153 | */
154 | private ?array $lastError = null;
155 |
156 | /**
157 | * Get the last error (null if last call succeeded).
158 | *
159 | * Use this AFTER a failed REST call to render an admin_notices banner
160 | * via ClientErrorRenderer.
161 | *
162 | * @return array<string, mixed>|null
163 | */
164 | public function lastError(): ?array
165 | {
166 | return $this->lastError;
167 | }
168 |
169 | /**
170 | * Clear the last error (e.g. after rendering it in admin_notices).
171 | */
172 | public function clearError(): void
173 | {
174 | $this->lastError = null;
175 | }
176 |
177 | /**
178 | * Normalized error response. NEVER throws for REST/transport failures —
179 | * always returns this shape so the caller can display the error inline.
180 | */
181 | private function fail(string $errorCode, string $message, int $httpStatus = 0, ?string $raw = null, ?string $route = null): array
182 | {
183 | $error = [
184 | 'success' => false,
185 | 'error_code' => $errorCode,
186 | 'message' => $message,
187 | 'http_status' => $httpStatus,
188 | 'when' => time(),
189 | ];
190 | if ($raw !== null) {
191 | $error['raw'] = $raw;
192 | }
193 | if ($route !== null) {
194 | $error['request_route'] = $route;
195 | }
196 | $this->lastError = $error;
197 | return $error;
198 | }
199 |
200 | private function request(string $method, string $route, string $body): array
201 | {
202 | // Reset error state for each new request.
203 | $this->lastError = null;
204 |
205 | // CF17.1: gate on (1) license key being set AND (2) server HMAC secret
206 | // being fetched via /public-key. The two are stored in DIFFERENT
207 | // option keys:
208 | // - LicenseManager::key() returns the Freemius license key
209 | // (per-site, from Freemius SDK)
210 | // - FreemiusAutoActivator::OPTION_SERVER_SECRET holds the shared
211 | // HMAC secret (network-wide, from /v1/public-key)
212 | // Using the wrong secret for HMAC signing silently fails every
213 | // signed request.
214 | if (!LicenseManager::isLicensed()) {
215 | return $this->fail(
216 | 'LICENSE_NOT_ACTIVATED',
217 | 'License not activated on this site. Activate the Freemius license first.',
218 | 0,
219 | null,
220 | $route
221 | );
222 | }
223 | $licenseKey = LicenseManager::key();
224 | $secret = EncryptedOption::get(FreemiusAutoActivator::OPTION_SERVER_SECRET, '');
225 | if ($secret === '') {
226 | // Try to fetch it now.
227 | FreemiusAutoActivator::ensureServerSecret();
228 | $secret = EncryptedOption::get(FreemiusAutoActivator::OPTION_SERVER_SECRET, '');
229 | }
230 | if ($secret === '') {
231 | return $this->fail(
232 | 'SERVER_SECRET_UNAVAILABLE',
233 | 'Could not retrieve HMAC secret from the server (/v1/public-key unreachable). Server may be down or blocked.',
234 | 0,
235 | null,
236 | $route
237 | );
238 | }
239 | // Sign with the FULL route (including namespace) so the server's
240 | // $request->get_route() returns the same string we hashed.
241 | // Server's get_route() returns '/{namespace}/{version}/{resource}',
242 | // e.g. '/swapads-server/v1/license/activate' — it does NOT
243 | // include the query string, so we must strip the `?…` suffix from
244 | // the canonical input before signing. (The URL itself still
245 | // carries the query, but only the path part is hashed.)
246 | // See F-DIAG-2026-07-30: /v1/backlinks/match?limit=5 was
247 | // mismatching because client signed the query string but server
248 | // dropped it.
249 | $pathForSigning = $route;
250 | $qPos = strpos($pathForSigning, '?');
251 | if ($qPos !== false) {
252 | $pathForSigning = substr($pathForSigning, 0, $qPos);
253 | }
254 | $fullRoute = '/' . self::NAMESPACE . $pathForSigning;
255 | $headers = (new RequestBuilder($licenseKey, $secret))->buildHeaders($method, $fullRoute, $body);
256 | // Prepend /wp-json/{NAMESPACE} so the route lands on the REST API,
257 | // not on the WP 404 handler.
258 | $restRoot = sprintf(self::REST_ROOT_TEMPLATE, $this->baseUrl, self::NAMESPACE);
259 | $url = $restRoot . $route;
260 |
261 | $requestHeaders = [
262 | 'Content-Type' => 'application/json',
263 | 'Accept' => 'application/json',
264 | ];
265 | foreach ($headers as $k => $v) {
266 | $requestHeaders[$k] = (string) $v;
267 | }
268 |
269 | $args = [
270 | 'method' => strtoupper($method),
271 | 'headers' => $requestHeaders,
272 | 'body' => $body,
273 | 'timeout' => $this->timeout,
274 | ];
275 |
276 | $response = wp_remote_request($url, $args);
277 | if (is_wp_error($response)) {
278 | return $this->fail(
279 | 'NETWORK_ERROR',
280 | 'Could not reach the SwapAds server: ' . $response->get_error_message(),
281 | 0,
282 | null,
283 | $route
284 | );
285 | }
286 | $code = (int) wp_remote_retrieve_response_code($response);
287 | $raw = (string) wp_remote_retrieve_body($response);
288 | $data = json_decode($raw, true);
289 | if (!is_array($data)) {
290 | // Non-JSON response (e.g. 404 HTML from a misconfigured URL).
291 | // Surface the HTTP status + a snippet of raw body so the operator
292 | // can see what the server actually returned.
293 | $snippet = strlen($raw) > 200 ? substr($raw, 0, 200) . '...' : $raw;
294 | return $this->fail(
295 | 'INVALID_RESPONSE',
296 | sprintf('Server returned HTTP %d but the body was not JSON. First 200 chars: %s', $code, $snippet),
297 | $code,
298 | $raw,
299 | $route
300 | );
301 | }
302 | $data['http_status'] = $code;
303 |
304 | // If the server returned an error envelope, persist it as lastError.
305 | if (isset($data['success']) && $data['success'] === false) {
306 | $this->lastError = [
307 | 'success' => false,
308 | 'error_code' => (string) ($data['error_code'] ?? 'UNKNOWN_ERROR'),
309 | 'message' => (string) ($data['message'] ?? 'Server returned an error without a message.'),
310 | 'http_status' => $code,
311 | 'request_route' => $route,
312 | 'when' => time(),
313 | ];
314 | }
315 |
316 | return $data;
317 | }
318 |
319 | // ============================================================================
320 | // AD CREDITS (F-AD-CREDITS, operator decision 2026-07-30)
321 | // ============================================================================
322 |
323 | /**
324 | * Record an ad IMPRESSION on this client's site (a partner's ad was displayed).
325 | * Operator earns 1 ad credit.
326 | *
327 | * @param string $adId Server-issued ad identifier.
328 | * @param string $idempotencyKey Client-generated unique key (UUID recommended).
329 | * @param bool $viewabilityFlag True if viewability check passed.
330 | *
331 | * @return array<string, mixed> Server response with balance snapshot.
332 | *
333 | * @since 1.4.0
334 | */
335 | public function earnAdOnImpression(string $adId, string $idempotencyKey, bool $viewabilityFlag = false): array
336 | {
337 | return $this->post('/v1/credits/earn-ad', [
338 | 'ad_id' => $adId,
339 | 'event' => 'impression',
340 | 'idempotency_key' => $idempotencyKey,
341 | 'viewability_flag' => $viewabilityFlag,
342 | ]);
343 | }
344 |
345 | /**
346 | * Record an ad CLICK on this client's site (visitor clicked partner's ad).
347 | * Operator earns 5 ad credits.
348 | *
349 | * @param string $adId
350 | * @param string $idempotencyKey
351 | * @param bool $viewabilityFlag
352 | *
353 | * @return array<string, mixed>
354 | *
355 | * @since 1.4.0
356 | */
357 | public function earnAdOnClick(string $adId, string $idempotencyKey, bool $viewabilityFlag = false): array
358 | {
359 | return $this->post('/v1/credits/earn-ad', [
360 | 'ad_id' => $adId,
361 | 'event' => 'click',
362 | 'idempotency_key' => $idempotencyKey,
363 | 'viewability_flag' => $viewabilityFlag,
364 | ]);
365 | }
366 |
367 | /**
368 | * Record that THIS client's ad was displayed on a partner's site (impression).
369 | * Operator spends 1 ad credit (balance may go negative — grace overserving).
370 | *
371 | * @param string $adId
372 | * @param string $idempotencyKey
373 | *
374 | * @return array<string, mixed>
375 | *
376 | * @since 1.4.0
377 | */
378 | public function spendAdOnImpression(string $adId, string $idempotencyKey): array
379 | {
380 | return $this->post('/v1/credits/spend-ad', [
381 | 'ad_id' => $adId,
382 | 'event' => 'impression',
383 | 'idempotency_key' => $idempotencyKey,
384 | ]);
385 | }
386 |
387 | /**
388 | * Record that THIS client's ad received a click on a partner's site.
389 | * Operator spends 5 ad credits.
390 | *
391 | * @param string $adId
392 | * @param string $idempotencyKey
393 | *
394 | * @return array<string, mixed>
395 | *
396 | * @since 1.4.0
397 | */
398 | public function spendAdOnClick(string $adId, string $idempotencyKey): array
399 | {
400 | return $this->post('/v1/credits/spend-ad', [
401 | 'ad_id' => $adId,
402 | 'event' => 'click',
403 | 'idempotency_key' => $idempotencyKey,
404 | ]);
405 | }
406 |
407 | /**
408 | * Read the current credit balance (returns split {backlinks: {...}, ads: {...}}).
409 | *
410 | * @return array<string, mixed>
411 | *
412 | * @since 1.4.0
413 | */
414 | /**
415 | * F200: List operator's own backlinks with placement stats.
416 | *
417 | * @param bool $includeRemoved Include soft-deleted rows.
418 | * @param int $limit Max results (clamped server-side at 500).
419 | * @return array<string, mixed> Response payload. Shape:
420 | * { success: bool, backlinks: array<int, array>, count: int }
421 | * Empty array on transport error (check lastError()).
422 | */
423 | public function listBacklinks(bool $includeRemoved = false, int $limit = 100): array
424 | {
425 | $q = '?limit=' . max(1, min(500, $limit));
426 | if ($includeRemoved) {
427 | $q .= '&include_removed=true';
428 | }
429 | return $this->get('/v1/backlinks/list' . $q);
430 | }
431 |
432 | /**
433 | * F121 (2026-07-31): list source-check status for the operator's
434 | * backlinks. Returns [{id, source_url, status, last_checked_at,
435 | * last_source_check_status, last_check_age_hours}, ...].
436 | *
437 | * @param string $status Optional status filter (active/removed).
438 | * @param int $limit Max rows (1..500, default 100).
439 | *
440 | * @return array<int|string, mixed>
441 | */
442 | public function listSourceChecks(string $status = '', int $limit = 100): array
443 | {
444 | $q = '?limit=' . max(1, min(500, $limit));
445 | if ($status !== '') {
446 | $q .= '&status=' . rawurlencode($status);
447 | }
448 | return $this->get('/v1/backlinks/source-check' . $q);
449 | }
450 |
451 | /**
452 | * F121 (2026-07-31): manually trigger a source-check for one or
453 | * more of the operator's backlinks.
454 | *
455 | * Single round-trip — same pattern as approve-bulk / reject-bulk.
456 | *
457 | * @param array<int, int> $ids Backlink IDs to recheck (1..50).
458 | *
459 | * @return array<int|string, mixed> {success, results: [{id, result, http_status, refunds}], count}
460 | */
461 | public function recheckSource(array $ids): array
462 | {
463 | $ids = array_values(array_unique(array_filter(array_map('intval', $ids), static fn (int $i): bool => $i > 0)));
464 | if (empty($ids)) {
465 | return ['success' => false, 'error_code' => 'INVALID_INPUT'];
466 | }
467 | if (count($ids) > 50) {
468 | $ids = array_slice($ids, 0, 50);
469 | }
470 | return $this->post('/v1/backlinks/source-check/recheck', ['backlink_ids' => $ids]);
471 | }
472 |
473 | /**
474 | * F121 (2026-07-31): trigger source-check for ALL of the operator's
475 | * active offers.
476 | *
477 | * @return array<int|string, mixed>
478 | */
479 | public function recheckAllSources(): array
480 | {
481 | return $this->post('/v1/backlinks/source-check/recheck', ['all' => true]);
482 | }
483 |
484 | /**
485 | * F230 (2026-07-31): list the audit log for the current operator.
486 | *
487 | * Returns the most recent N events for this site. Filterable by action
488 | * (e.g. "backlink.approve", "approval.reject", "source_check.404") and
489 | * entity_type (e.g. "backlink", "approval", "source_check").
490 | *
491 | * @param string $action Optional exact-match action filter.
492 | * @param string $entityType Optional exact-match entity_type filter.
493 | * @param int $limit Max rows (1..500, default 50).
494 | * @param int $offset Pagination offset (default 0).
495 | *
496 | * @return array<int|string, mixed>
497 | */
498 | public function listAuditLog(
499 | string $action = '',
500 | string $entityType = '',
501 | int $limit = 50,
502 | int $offset = 0
503 | ): array {
504 | $limit = max(1, min(500, $limit));
505 | $offset = max(0, $offset);
506 | $q = '?limit=' . $limit . '&offset=' . $offset;
507 | if ($action !== '') {
508 | $q .= '&action=' . rawurlencode($action);
509 | }
510 | if ($entityType !== '') {
511 | $q .= '&entity_type=' . rawurlencode($entityType);
512 | }
513 | return $this->get('/v1/audit-log' . $q);
514 | }
515 |
516 | /**
517 | * F220 (2026-07-30): list approvals of MY backlinks.
518 | *
519 | * Operator A offers; Operator B approves. A wants to see who
520 | * approved what. Returns a list of {approval_id, backlink_id,
521 | * status, approved_at, approver_site_url, approver_site_domain,
522 | * source_url, anchor_text, ...}.
523 | *
524 | * @param int $backlinkId Filter to one of A's backlinks (0 = all).
525 | * @param string $status '' = any; 'pending'|'approved'|'placed'|'rejected'.
526 | * @param int $limit Max rows (1..500, default 100).
527 | *
528 | * @return array<int|string, mixed> Shape {success, items, count, filter}.
529 | */
530 | public function listBacklinkApprovals(int $backlinkId = 0, string $status = '', int $limit = 100): array
531 | {
532 | $limit = max(1, min(500, $limit));
533 | $q = '?limit=' . $limit;
534 | if ($backlinkId > 0) {
535 | $q .= '&backlink_id=' . $backlinkId;
536 | }
537 | if ($status !== '') {
538 | $allowed = ['pending', 'approved', 'placed', 'rejected'];
539 | if (in_array($status, $allowed, true)) {
540 | $q .= '&status=' . rawurlencode($status);
541 | }
542 | }
543 | return $this->get('/v1/backlinks/approvals' . $q);
544 | }
545 |
546 | /**
547 | * F223 (2026-07-30): reject a single backlink offer.
548 | *
549 | * Sets the approval row's status=rejected + rejected_at=NOW + optional
550 | * reason. Does NOT spend credits (operators don't pay for rejecting).
551 | *
552 | * @param int $backlinkId Backlink offer ID.
553 | * @param string $reason Optional human-readable reason (max 500 chars).
554 | *
555 | * @return array<int|string, mixed> Shape {success, rejection_id, backlink_id, reason}.
556 | */
557 | public function rejectBacklink(int $backlinkId, string $reason = ''): array
558 | {
559 | $body = ['backlink_id' => max(1, $backlinkId)];
560 | if ($reason !== '') {
561 | $body['reason'] = mb_substr($reason, 0, 500);
562 | }
563 | return $this->post('/v1/backlinks/reject', $body);
564 | }
565 |
566 | /**
567 | * F223 (2026-07-30): bulk reject multiple backlink offers.
568 | *
569 | * Single round-trip — same pattern as approve-bulk. Up to 50 ids.
570 | * Returns {success, rejected[], failed[], count, reason}.
571 | *
572 | * @param array<int, int> $ids Backlink IDs to reject.
573 | * @param string $reason Optional reason (max 500 chars).
574 | *
575 | * @return array<int|string, mixed>
576 | */
577 | public function rejectBacklinkBulk(array $ids, string $reason = ''): array
578 | {
579 | $body = ['backlink_ids' => array_values(array_unique(array_filter(array_map('intval', $ids), static fn (int $i): bool => $i > 0)))];
580 | if (count($body['backlink_ids']) > 50) {
581 | $body['backlink_ids'] = array_slice($body['backlink_ids'], 0, 50);
582 | }
583 | if ($reason !== '') {
584 | $body['reason'] = mb_substr($reason, 0, 500);
585 | }
586 | return $this->post('/v1/backlinks/reject-bulk', $body);
587 | }
588 |
589 | /**
590 | * F200: Update a backlink (whitelist of mutable fields).
591 | *
592 | * @param int $id Backlink ID.
593 | * @param array<string, mixed> $fields Mutable fields (anchor_text, hint,
594 | * rel_attribute, max_external_on_source,
595 | * status, target_audience).
596 | * @return array<string, mixed> Response payload. Shape:
597 | * { success: bool, backlink: array }
598 | * Empty array on transport error (check lastError()).
599 | */
600 | public function updateBacklink(int $id, array $fields): array
601 | {
602 | return $this->post('/v1/backlinks/update', [
603 | 'id' => $id,
604 | 'fields' => $fields,
605 | ]);
606 | }
607 |
608 | /**
609 | * F200: Soft-delete a backlink (status='removed').
610 | *
611 | * @param int $id Backlink ID.
612 | * @return array<string, mixed> Response payload. Shape:
613 | * { success: bool, id: int, status: string }
614 | * Empty array on transport error (check lastError()).
615 | */
616 | public function deleteBacklink(int $id): array
617 | {
618 | return $this->post('/v1/backlinks/delete', [
619 | 'id' => $id,
620 | ]);
621 | }
622 |
623 | public function creditsBalance(): array
624 | {
625 | return $this->get('/v1/credits/balance');
626 | }
627 | }