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
12declare(strict_types=1);
13
14namespace SwapAds\Client\Api;
15
16use SwapAds\Client\License\FreemiusAutoActivator;
17use SwapAds\Client\License\LicenseManager;
18use SwapAds\Client\Security\EncryptedOption;
19use SwapAds\Shared\Http\RequestBuilder;
20
21/**
22 * Class RestClient.
23 *
24 * @since 1.0.0
25 */
26final 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}