VoidhashDocs
DocumentationAPI reference
Dashboard
Get started
Introduction
Quickstart
Setup
Setup overview
Create a project
Products and perks
Paywall locations
CLI
Paywall components
Components
Develop a component
Editor panels
Component definition
Publishing
Installing and capabilities
SDKs
Swift
Kotlin
React Native
Web
Store setup
Connect the App Store
Connect Google Play
Connect Stripe
Record payments from other sources
API libraries
Node.js
PHP
Rust
Go
Resources
MCP server
Ad measurement
Meta ads
Migrate in observer mode
Delete a person's data
PHP library
Check access from your backend
Receive webhooks
Custom payment providers

Custom payment providers

Report payments from Paddle, Lemon Squeezy or any other provider, and answer Voidhash's requests on a provider endpoint, with the PHP library.

A payment source records payments you take outside the App Store, Google Play and Stripe, so they grant access and count as revenue. This page shows how to do both halves of it with the PHP library: report what happens at your provider, and serve the endpoint Voidhash calls to import products and history, check subscriptions, cancel, refund and start checkouts.

Read Record payments from other sources first. It explains what a payment and a subscription state mean, and how to create the source and map its product keys in Studio. This page does not repeat those rules.

Before you start you need:

  • The library set up with a secret key that has the project:purchases:write scope, or full access.
  • The source's payment provider id. Its Payments API tab in Studio shows it in the Transactions endpoint address, after /payment-providers/. The examples read it from VOIDHASH_PAYMENT_SOURCE_ID.
  • PHP 8.2 or newer. The payment source classes are readonly classes.

Report payments

Get a handle for the source from paymentSources. It takes the payment provider id, not the slug, and makes no request of its own.

use Voidhash\VoidhashClient;

$voidhash = VoidhashClient::create(getenv("VOIDHASH_SECRET_KEY"));
$paddle = $voidhash->paymentSources->source(getenv("VOIDHASH_PAYMENT_SOURCE_ID"));

Every report names the record by your provider's own id: the transaction id for a payment, the subscription id for a state. Amounts are integers in minor units, and dates are DateTimeImmutable values in any time zone.

A one-time purchase

A Charge without a subscription completes a one-time purchase of the product mapped to its product key.

use Voidhash\PaymentSources\Charge;

$result = $paddle->charge("txn_123", new Charge(
    productKey: "LIFETIME",
    amount: 4900,
    currency: "EUR",
    occurredAt: new DateTimeImmutable("2026-10-20T09:30:00Z"),
    distinctId: "user_123",
    fee: 245,
    tax: 818,
));

Name the buyer with distinctId, with customerId (your provider's id for the customer, sent as customer.externalId), or with both. See Customers without a distinct id.

A subscription payment

A charge that pays for a subscription period also names the subscription and the period. The first one starts the subscription, later ones renew it.

$paddle->charge("txn_124", new Charge(
    productKey: "pri_monthly",
    amount: 999,
    currency: "EUR",
    occurredAt: new DateTimeImmutable("2026-10-01T00:00:03Z"),
    distinctId: "user_123",
    subscriptionId: "sub_9",
    periodStart: new DateTimeImmutable("2026-10-01T00:00:00Z"),
    periodEnd: new DateTimeImmutable("2026-11-01T00:00:00Z"),
));

Report the subscription's state as well: the charge books revenue, the state decides access.

A refund

Report a refund your provider made under the refund's own id, naming the charge it returns money for. Its currency must be the charge's. A refund of less than the charge is partial; see Refunds.

use Voidhash\PaymentSources\Refund;

$paddle->refund("re_1", new Refund(
    refundsTransactionId: "txn_123",
    amount: 4900,
    currency: "EUR",
    occurredAt: new DateTimeImmutable("2026-10-22T14:00:00Z"),
));

What comes back

charge() and refund() return a PaymentSourceTransaction:

GetterReturns
getId()The id you reported it under.
getType()charge or refund.
getStatus()applied when it was recorded, pending when it waits.
getPendingReason()product_not_mapped or charge_not_recorded while pending, else null.
getPurchaseId()The purchase it recorded or refunded, once applied.
getBookedAmount()The amount it recorded. A refund can record less than you sent.

A pending payment needs nothing more from you: it is recorded on its own once its product key is mapped or its charge arrives. When refunds of the same charge race, getBookedAmount() tells you what each one actually returned.

Retry safely

Reporting is idempotent under your id. Sending the same id with the same values again returns the same result, so retry a report whenever it failed or you are unsure it arrived, for example when your provider redelivers a webhook. Sending the same id with different values throws: a recorded payment is never edited. Refund it and report the right payment under a new id instead. See Retries and corrections.

Every API error is a Voidhash\Exception\ApiException. Branch on getType() and getErrorCode(). A request that never reached Voidhash throws the HTTP client's PSR-18 Psr\Http\Client\ClientExceptionInterface instead; retrying it under the same id is safe too.

use Voidhash\Exception\ApiException;

try {
    $paddle->charge("txn_123", $charge);
} catch (ApiException $error) {
    if ($error->getErrorCode() !== "transaction_conflict") {
        throw $error; // Retry rate_limited, unavailable and internal later, under the same id.
    }
    // 409 conflict: txn_123 was already recorded with other values. Retrying cannot help.
    error_log("txn_123 conflicts: " . $error->getMessage());
}

Sandbox payments

Report test payments with environment: Environment::Sandbox, from Voidhash\PaymentSources\Environment. They grant access like real ones but stay out of production revenue. Charge, Refund and SubscriptionState all take it; the default is Environment::Production.

Report subscription state

Send the subscription's latest state whenever your provider changes it, under the subscription's id. A state carries no money; report each payment as a charge.

use Voidhash\PaymentSources\SubscriptionState;
use Voidhash\PaymentSources\SubscriptionStatus;

$state = $paddle->subscription("sub_9", new SubscriptionState(
    productKey: "pri_monthly",
    status: SubscriptionStatus::Active,
    currentPeriodStart: new DateTimeImmutable("2026-10-01T00:00:00Z"),
    currentPeriodEnd: new DateTimeImmutable("2026-11-01T00:00:00Z"),
    isAutoRenewing: true,
    updatedAt: new DateTimeImmutable("2026-10-01T00:00:05Z"),
    distinctId: "user_123",
));

SubscriptionStatus has the cases Trialing, Active, PastDue, Canceled and Expired. Set canceledAt with Canceled; it is when access ended. What each status does to access is described under Subscriptions.

updatedAt orders the states. Use the time your provider last changed the subscription, not the time you send the report, so that webhooks may arrive in any order. The call returns a PaymentSourceSubscription:

GetterReturns
getStatus()applied, superseded when Voidhash already has a newer state, or pending.
getPendingReason()product_not_mapped or charge_not_recorded while pending, else null.
getSubscriptionId()Voidhash's id for the subscription, once there is one.

A superseded state is not an error. It means a newer one arrived first, and nothing changed.

Translate your provider's webhooks

Most integrations report from the provider's webhooks. Verify the provider's signature, read the event, and turn it into the calls above. If reporting fails, answer with a 5xx so the provider redelivers; the repeated report is safe.

This sketch handles a provider with Paddle-like events. The PaddleWebhooks helper and the event's field names stand in for your provider's SDK and payloads.

public/billing-webhook.php
use Voidhash\PaymentSources\Charge;
use Voidhash\PaymentSources\Refund;
use Voidhash\PaymentSources\SubscriptionState;
use Voidhash\PaymentSources\SubscriptionStatus;

$event = PaddleWebhooks::verify(file_get_contents("php://input"), getallheaders());
$data = $event["data"];

match ($event["type"]) {
    "transaction.completed" => $paddle->charge($data["id"], new Charge(
        productKey: $data["price_id"],
        amount: $data["total"],
        currency: $data["currency"],
        occurredAt: new DateTimeImmutable($data["billed_at"]),
        distinctId: $data["custom_data"]["distinct_id"] ?? null,
        customerId: $data["customer_id"],
        fee: $data["fee"],
        tax: $data["tax"],
        subscriptionId: $data["subscription_id"] ?? null,
        periodStart: isset($data["period"]) ? new DateTimeImmutable($data["period"]["starts_at"]) : null,
        periodEnd: isset($data["period"]) ? new DateTimeImmutable($data["period"]["ends_at"]) : null,
    )),
    "refund.completed" => $paddle->refund($data["id"], new Refund(
        refundsTransactionId: $data["transaction_id"],
        amount: $data["amount"],
        currency: $data["currency"],
        occurredAt: new DateTimeImmutable($data["completed_at"]),
    )),
    "subscription.updated" => $paddle->subscription($data["id"], new SubscriptionState(
        productKey: $data["price_id"],
        status: match ($data["status"]) {
            "trialing" => SubscriptionStatus::Trialing,
            "active" => SubscriptionStatus::Active,
            "past_due" => SubscriptionStatus::PastDue,
            "paused", "canceled" => SubscriptionStatus::Canceled,
        },
        currentPeriodStart: new DateTimeImmutable($data["current_period"]["starts_at"]),
        currentPeriodEnd: new DateTimeImmutable($data["current_period"]["ends_at"]),
        isAutoRenewing: $data["scheduled_change"] === null,
        updatedAt: new DateTimeImmutable($data["updated_at"]),
        customerId: $data["customer_id"],
        canceledAt: isset($data["canceled_at"]) ? new DateTimeImmutable($data["canceled_at"]) : null,
    )),
    default => null,
};

http_response_code(200);

Map your provider's statuses onto the five Voidhash statuses explicitly; they rarely match one to one.

Cancel and refund at the provider

cancelSubscription() and refundTransaction() work the other way round. Instead of reporting what your provider did, they ask Voidhash to have your provider endpoint do it, and record what the endpoint answers. They need an endpoint that implements the operation.

// At the end of the paid period, or now with isImmediate: true.
$state = $paddle->cancelSubscription("sub_9");
$state = $paddle->cancelSubscription("sub_9", isImmediate: true);

// Everything left of the charge, or an amount in minor units, with an optional reason.
$refund = $paddle->refundTransaction("txn_123");
$refund = $paddle->refundTransaction("txn_123", amount: 1000, reason: "requested_by_customer");

cancelSubscription() returns the PaymentSourceSubscription Voidhash recorded, and refundTransaction() the refund's PaymentSourceTransaction. Do not confuse refundTransaction() with refund(): refund() reports a refund your provider already made, refundTransaction() asks for a new one. When your provider later sends you a webhook for the same cancellation or refund and you report it, nothing is recorded twice.

When the endpoint cannot act, Voidhash records nothing and the call throws an ApiException whose message says why:

getType()getErrorCode()Meaning
conflictpayment_source_endpoint_not_configuredThe source has no endpoint.
conflictpayment_source_endpoint_unsupportedThe endpoint does not implement the operation.
conflictpayment_source_endpoint_rejectedThe endpoint refused, such as with not_found.
unavailablepayment_source_endpoint_unavailableThe endpoint did not answer in time or answered a 5xx.
unavailablepayment_source_endpoint_invalid_responseThe endpoint's answer broke the protocol.

You can retry the two unavailable errors later. The conflict errors persist until you change the endpoint or the request.

Build a provider endpoint

The endpoint is one HTTPS route that answers Voidhash's signed requests. ProviderHandler does the protocol; you write a class that implements an interface for each operation your provider supports. Implement only those you need.

InterfaceMethodOperation
CatalogSourcelistCatalog(CatalogRequest): CatalogPagecatalog.list
HistorySourcelistHistory(HistoryRequest): HistoryPagehistory.list
SubscriptionSourcegetSubscription(SubscriptionRequest): ?Subscriptionsubscription.get
SubscriptionCancellationcancelSubscription(CancelSubscriptionRequest): Subscriptionsubscription.cancel
TransactionRefundsrefundTransaction(RefundTransactionRequest): Transactiontransaction.refund
CheckoutSourcecreateCheckout(CheckoutRequest): Checkoutcheckout.create

Every type lives in Voidhash\Provider, except SubscriptionStatus and Environment, which come from Voidhash\PaymentSources. Requests and results are readonly classes. Every request carries the environment Voidhash asks about, so a sandbox request can go to your provider's sandbox.

In the snippets below, $this->paddle stands for your own client for the provider's API. Add each method to the same class, and its interface to the class's implements list.

Products

listCatalog() returns one page of products, at most 1000. Each product's key is the product key its payments name, and kind is ProductKind::Subscription, OneTime or Consumable. Return the next page's cursor in nextCursor, or null on the last page.

src/PaddleProvider.php
use Voidhash\Provider\CatalogPage;
use Voidhash\Provider\CatalogProduct;
use Voidhash\Provider\CatalogRequest;
use Voidhash\Provider\CatalogSource;
use Voidhash\Provider\ProductKind;

final class PaddleProvider implements CatalogSource
{
    public function __construct(private readonly PaddleApi $paddle)
    {
    }

    public function listCatalog(CatalogRequest $request): CatalogPage
    {
        $page = $this->paddle->listPrices(after: $request->cursor);

        return new CatalogPage(
            items: array_map(
                fn (array $price) => new CatalogProduct(
                    key: $price["id"],
                    name: $price["description"],
                    kind: $price["billing_cycle"] === null ? ProductKind::OneTime : ProductKind::Subscription,
                ),
                $page["data"],
            ),
            nextCursor: $page["next"],
        );
    }
}

History

listHistory() pages through records created before $request->before. Voidhash asks for HistoryKind::Subscriptions first, then HistoryKind::Transactions, with its own cursors. Return only items of the requested kind: Subscription items for subscriptions, Transaction items for transactions. Anything after before reaches Voidhash through your reports.

use Voidhash\Provider\HistoryKind;
use Voidhash\Provider\HistoryPage;
use Voidhash\Provider\HistoryRequest;
use Voidhash\Provider\Transaction;
use Voidhash\Provider\TransactionType;

public function listHistory(HistoryRequest $request): HistoryPage
{
    if ($request->kind === HistoryKind::Subscriptions) {
        $page = $this->paddle->listSubscriptions(createdBefore: $request->before, after: $request->cursor);

        return new HistoryPage(array_map($this->toSubscription(...), $page["data"]), $page["next"]);
    }

    $page = $this->paddle->listTransactions(createdBefore: $request->before, after: $request->cursor);

    return new HistoryPage(
        items: array_map(fn (array $txn) => new Transaction(
            id: $txn["id"],
            type: $txn["refunds"] === null ? TransactionType::Charge : TransactionType::Refund,
            amount: $txn["total"],
            currency: $txn["currency"],
            occurredAt: new DateTimeImmutable($txn["billed_at"]),
            customerId: $txn["customer_id"],
            productKey: $txn["price_id"],
            subscriptionId: $txn["subscription_id"],
            refundsTransactionId: $txn["refunds"],
        ), $page["data"]),
        nextCursor: $page["next"],
    );
}

Transaction has the fields of a reported charge or refund, plus id and type. Records you also report are recorded once.

Subscriptions

getSubscription() returns a subscription's current state, or null when your provider does not know it. Voidhash asks when a period ended without a renewal payment or a final state.

Subscription has the fields of a reported state plus id. Its renewal flag is called willRenew, where SubscriptionState calls it isAutoRenewing.

use Voidhash\PaymentSources\SubscriptionStatus;
use Voidhash\Provider\Subscription;
use Voidhash\Provider\SubscriptionRequest;

public function getSubscription(SubscriptionRequest $request): ?Subscription
{
    $subscription = $this->paddle->findSubscription($request->id);

    return $subscription === null ? null : $this->toSubscription($subscription);
}

private function toSubscription(array $sub): Subscription
{
    return new Subscription(
        id: $sub["id"],
        productKey: $sub["price_id"],
        status: match ($sub["status"]) {
            "trialing" => SubscriptionStatus::Trialing,
            "active" => SubscriptionStatus::Active,
            "past_due" => SubscriptionStatus::PastDue,
            "paused", "canceled" => SubscriptionStatus::Canceled,
        },
        currentPeriodStart: new DateTimeImmutable($sub["current_period"]["starts_at"]),
        currentPeriodEnd: new DateTimeImmutable($sub["current_period"]["ends_at"]),
        willRenew: $sub["scheduled_change"] === null,
        updatedAt: new DateTimeImmutable($sub["updated_at"]),
        customerId: $sub["customer_id"],
        canceledAt: isset($sub["canceled_at"]) ? new DateTimeImmutable($sub["canceled_at"]) : null,
    );
}

Cancellations

cancelSubscription() cancels at the provider and returns the subscription's state afterwards. $request->atPeriodEnd is true to stop renewal and keep access until the period ends, and false to end it now. Voidhash records the state you return as if you had reported it.

use Voidhash\Provider\CancelSubscriptionRequest;
use Voidhash\Provider\ProviderErrorCode;
use Voidhash\Provider\ProviderException;
use Voidhash\Provider\Subscription;

public function cancelSubscription(CancelSubscriptionRequest $request): Subscription
{
    $subscription = $this->paddle->cancelSubscription(
        $request->id,
        effectiveFrom: $request->atPeriodEnd ? "next_billing_period" : "immediately",
    ) ?? throw new ProviderException(ProviderErrorCode::NotFound, "No subscription {$request->id}");

    return $this->toSubscription($subscription);
}

Refunds

refundTransaction() refunds the charge $request->transactionId: $request->amount in minor units, or everything left of it when that is null. $request->reason is optional. Return the refund as a Transaction with TransactionType::Refund and refundsTransactionId naming the charge.

use Voidhash\Provider\RefundTransactionRequest;
use Voidhash\Provider\Transaction;
use Voidhash\Provider\TransactionType;

public function refundTransaction(RefundTransactionRequest $request): Transaction
{
    $refund = $this->paddle->refund($request->transactionId, $request->amount, $request->reason);

    return new Transaction(
        id: $refund["id"],
        type: TransactionType::Refund,
        amount: $refund["amount"],
        currency: $refund["currency"],
        occurredAt: new DateTimeImmutable($refund["created_at"]),
        refundsTransactionId: $request->transactionId,
    );
}

Checkouts

createCheckout() starts a checkout for $request->productKey and returns where to send the buyer. The buyer is named by $request->distinctId, $request->customerId, both or neither. Pass the distinct id on to your provider so it comes back in the payment's webhook, and report that payment as usual. Set expiresAt when the link stops working. Checkouts always arrive as production requests; see Test checkouts.

use Voidhash\Provider\Checkout;
use Voidhash\Provider\CheckoutRequest;

public function createCheckout(CheckoutRequest $request): Checkout
{
    $session = $this->paddle->createCheckout(
        priceId: $request->productKey,
        customerId: $request->customerId,
        customData: ["distinct_id" => $request->distinctId],
        successUrl: $request->successUrl,
        cancelUrl: $request->cancelUrl,
    );

    return new Checkout(url: $session["url"], expiresAt: new DateTimeImmutable($session["expires_at"]));
}

Your web app starts the checkout with the web SDK's createCheckout(); see Start a checkout.

Answer errors deliberately

Throw a ProviderException to fail a request with a code and message of your choice. Voidhash receives both as they are.

ProviderErrorCodeWhen to use it
NotFoundYour provider has nothing under the id, such as an unknown transaction.
InvalidRequestThe input makes no sense to your provider, such as refunding too much.
UnavailableYour provider is down. Voidhash retries reads later.
throw new ProviderException(ProviderErrorCode::Unavailable, "Paddle is not responding");

Any other exception is answered as provider_error. Its message reaches Voidhash only for sandbox requests; production requests get a generic message, so your internal errors stay private.

When you cancel or refund from Voidhash, NotFound and InvalidRequest surface as payment_source_endpoint_rejected, Unavailable and other exceptions as payment_source_endpoint_unavailable, and an invalid_result as payment_source_endpoint_invalid_response.

What the handler does for you

Create the handler with the source's slug, the endpoint signing secret and your class:

use Voidhash\Provider\ProviderHandler;

$handler = new ProviderHandler("paddle", getenv("VOIDHASH_PROVIDER_SECRET"), new PaddleProvider($paddleApi));

For each request it:

  • checks the x-voidhash-signature against the raw body and refuses a timestamp more than 5 minutes from your server's clock, answering invalid_signature;
  • refuses a request for another source or protocol version, and input that breaks the protocol, as invalid_request;
  • answers describe with the operations your class implements, which Test connection in Studio shows, and unsupported_operation for the rest;
  • checks what your methods return before sending it. A result the protocol does not allow, such as a page of more than 1000 items or a refund of type charge, is answered as invalid_result with the field at fault.

Mount the endpoint

The handler must receive the raw request body exactly as Voidhash sent it. Don't let middleware parse and re-encode the JSON before it, or every signature check fails.

ProviderHandler is a PSR-15 RequestHandlerInterface, so any PSR-15 stack can route to it. With Slim:

public/index.php
use Psr\Http\Message\ServerRequestInterface;

$app->post("/voidhash/provider", fn (ServerRequestInterface $request) => $handler->handle($request));

respondToGlobals() reads php://input and the request headers, then sends the status, content type and body.

public/voidhash-provider.php
use Voidhash\Provider\ProviderHandler;

require __DIR__ . "/../vendor/autoload.php";

$handler = new ProviderHandler("paddle", getenv("VOIDHASH_PROVIDER_SECRET"), new PaddleProvider($paddleApi));
$handler->respondToGlobals();

handlePayload() takes the raw body and the headers, for frameworks that are not PSR-7. Exclude the route from CSRF protection.

routes/api.php
use Illuminate\Http\Request;
use Voidhash\Provider\ProviderHandler;

Route::post("/voidhash/provider", function (Request $request, ProviderHandler $handler) {
    $answer = $handler->handlePayload($request->getContent(), $request->headers->all());

    return response((string) $answer->getBody(), $answer->getStatusCode())
        ->header("content-type", $answer->getHeaderLine("content-type"));
});
src/Controller/VoidhashProviderController.php
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Voidhash\Provider\ProviderHandler;

final class VoidhashProviderController
{
    public function __construct(private readonly ProviderHandler $handler)
    {
    }

    #[Route("/voidhash/provider", methods: ["POST"])]
    public function __invoke(Request $request): Response
    {
        $answer = $this->handler->handlePayload($request->getContent(), $request->headers->all());

        return new Response((string) $answer->getBody(), $answer->getStatusCode(), [
            "content-type" => $answer->getHeaderLine("content-type"),
        ]);
    }
}

Pass the handler's status through unchanged: Voidhash reads it and the error code in the body.

Test the endpoint

ProviderTester::call() sends a correctly signed request through the handler, without a server, and returns its PSR-7 response. Give it the operation, its input, and optionally the environment. Dates in the input may be DateTimeInterface values. Include every input field of the operation, null where it has no value, as Voidhash does.

tests/PaddleProviderTest.php
use PHPUnit\Framework\TestCase;
use Voidhash\PaymentSources\Environment;
use Voidhash\Provider\ProviderHandler;
use Voidhash\Provider\ProviderTester;

final class PaddleProviderTest extends TestCase
{
    public function testItListsTheCatalog(): void
    {
        $handler = new ProviderHandler("paddle", "vhps_test", new PaddleProvider(new FakePaddleApi()));

        $response = ProviderTester::call($handler, "catalog.list", ["cursor" => null]);
        $body = json_decode((string) $response->getBody(), true);

        self::assertSame(200, $response->getStatusCode());
        self::assertSame(["key" => "pri_monthly", "name" => "Pro monthly", "kind" => "subscription"], $body["result"]["items"][0]);
    }

    public function testAnUnknownChargeIsNotFound(): void
    {
        $handler = new ProviderHandler("paddle", "vhps_test", new PaddleProvider(new FakePaddleApi()));

        $response = ProviderTester::call(
            $handler,
            "transaction.refund",
            ["transactionId" => "txn_missing", "amount" => null, "reason" => null],
            Environment::Sandbox,
        );

        self::assertSame(404, $response->getStatusCode());
        self::assertSame("not_found", json_decode((string) $response->getBody(), true)["error"]["code"]);
    }
}

A successful answer is {"id": …, "result": …} and a failed one {"id": …, "error": {"code", "message"}}, with the status the code implies.

Connect it in Studio

Deploy the endpoint at a public HTTPS address. On the source's Provider endpoint tab, enter its URL and save: Voidhash shows the signing secret once. Set it as VOIDHASH_PROVIDER_SECRET on your server, then select Test connection. See Connect an endpoint for timeouts, retries and rotating the secret.

Complete example

The PHP backend example reports a stand-in provider's payments from its webhooks and serves every provider operation, with a script that signs requests the way Voidhash does.

PreviousReceive webhooksNextRust library

On this page

Report paymentsA one-time purchaseA subscription paymentA refundWhat comes backRetry safelySandbox paymentsReport subscription stateTranslate your provider's webhooksCancel and refund at the providerBuild a provider endpointProductsHistorySubscriptionsCancellationsRefundsCheckoutsAnswer errors deliberatelyWhat the handler does for youMount the endpointTest the endpointConnect it in StudioComplete example