Search by

ratespecial / phonexa

kpiercers

API library to interact with the Phonexa ping-tree platform

Package info

github.com/ratespecial/phonexa-php

pkg:composer/ratespecial/phonexa

Statistics

Installs: 30

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

1.2.0 2026-10-09 20:34 UTC

This package is auto-updated.

Last update: 2026-10-09 20:35:24 UTC


README

PHP API client for the Phonexa ping-tree platform, built on Saloon.

Phonexa is made up of several sub-services. Each gets its own namespace, connector and credentials under src/. The first one implemented is LMS Sync — posting leads to be sold.

Requirements

  • PHP 8.3+

Installation

composer require ratespecial/phonexa

Laravel

The service provider is auto-discovered. Publish the config if you want to edit it directly:

php artisan vendor:publish --provider="Ratespecial\Phonexa\ServiceProvider\PhonexaServiceProvider"

Then set the base URL:

PHONEXA_LMS_SYNC_BASE_URL=https://leads-inst1-client.phonexa.com

# Optional fallbacks, used only when a lead does not carry its own values
PHONEXA_LMS_SYNC_API_ID=
PHONEXA_LMS_SYNC_API_PASSWORD=
PHONEXA_LMS_SYNC_PRODUCT_ID=218

# Optional
PHONEXA_LMS_SYNC_TEST_MODE=false

Note the base URL is the leads- host and is specific to your Phonexa instance — it is not the cp- host the API documentation itself is served from.

API credentials normally belong on each lead rather than in the environment — see Credentials.

Basic usage

With Laravel

use Ratespecial\Phonexa\LmsSync\LmsSyncService;
use Ratespecial\Phonexa\LmsSync\Models\Lead;

public function __construct(private readonly LmsSyncService $phonexa) {}

public function sell(PublisherChannel $channel): void
{
    $lead = new Lead([
        'apiId'         => $channel->apiId,
        'apiPassword'   => $channel->apiPassword,
        'productId'     => 218,
        'price'         => 0.01,
        'firstName'     => 'John',
        'lastName'      => 'Smith',
        'email'         => 'john.n@yahoo.com',
        'cellPhone'     => '5555555555',
        'address'       => 'my address',
        'zip'           => '90210',
        'dob'           => '1980-10-21',
        'unsecuredDebt' => 10000,
    ]);

    $response = $this->phonexa->postLead($lead);

    if ($response->isSold()) {
        // $response->leadId, $response->price, $response->redirectUrl
    }
}

Without Laravel

use Ratespecial\Phonexa\LmsSync\LmsSyncConnector;
use Ratespecial\Phonexa\LmsSync\LmsSyncService;

$connector = new LmsSyncConnector(
    baseUrl: 'https://leads-inst1-client.phonexa.com',
);

$service = new LmsSyncService($connector);

apiId and apiPassword may also be passed to the connector as fallback defaults — see Credentials.

Credentials

Phonexa issues an API ID and password per publisher channel, so an application selling for several publishers holds a different pair for each. Credentials therefore normally travel on the lead:

$lead->apiId = $channel->apiId;
$lead->apiPassword = $channel->apiPassword;

The connector's credentials — PHONEXA_LMS_SYNC_API_ID and PHONEXA_LMS_SYNC_API_PASSWORD in Laravel, or the apiId/apiPassword constructor arguments — are only a fallback, applied to leads that carry none of their own. They are worth setting when the application posts through a single channel. It's also wise to set them to a house account for traffic that goes direct-to-site.

Status checks (checkLeadStatus(), waitForLeadStatus()) carry no lead, so they can only authenticate with the connector's credentials. To poll a lead posted with per-channel credentials, build a connector with that channel's pair:

$connector = new LmsSyncConnector($baseUrl, $channel->apiId, $channel->apiPassword);

(new LmsSyncService($connector))->waitForLeadStatus($leadId);

Leads

Phonexa defines only four universal system fields — apiId, apiPassword, productId and tPar. Every other field is ad-hoc and differs per product, so Lead does not whitelist field names. Set whatever the product's API doc lists:

$lead = new Lead();

$lead->productId = 218;
$lead->firstName = 'John';           // ad-hoc, stored in the field bag
$lead->unsecuredDebt = 10000;
$lead->set('consentEmailSms', 'YES');

$lead->setTPar('affiliateId', '123'); // pass-through, forwarded to buyers but not stored

Phonexa field names are case sensitive. firstName works, firstname does not.

apiId and apiPassword set on a lead take precedence over the connector's fallback credentials — see Credentials.

Using your own model

Lead is a convenience, not a requirement. Anything implementing ProvidesLeadData can be posted — an Eloquent model, a DTO, a form object:

use Ratespecial\Phonexa\LmsSync\Contracts\ProvidesLeadData;

class DebtApplication extends Model implements ProvidesLeadData
{
    public function getLeadData(): array
    {
        return [
            'productId'     => 218,
            'price'         => 0.01,
            'firstName'     => $this->first_name,
            'lastName'      => $this->last_name,
            'email'         => $this->email,
            'dob'           => $this->date_of_birth->format('Y-m-d'),
            'unsecuredDebt' => $this->debt_total,
        ];
    }
}

$response = $service->postLead($application);

Outcomes

Phonexa answers with HTTP 200 whatever happens and reports the result in a status field:

Status Meaning Result
1 Sold $response->isSold(), with leadId, price and redirectUrl
2 Reject $response->isRejected()
3 In progress $response->isInProgress() — poll for the outcome
4 Validation or auth failure throws LeadValidationException, narrowed to DuplicateLeadException, UserDisabledException or AuthorizationFailedException where the error text says so
5 Unknown lead throws LeadNotFoundException

A reject means no buyer wanted the lead. That is an ordinary business outcome, so it returns normally rather than throwing:

$response = $service->postLead($lead);

if ($response->isSold()) {
    return redirect()->away($response->redirectUrl);
}

// Rejected — nothing went wrong, there was just no buyer.

Validation failures do throw, and carry Phonexa's field-level errors:

use Ratespecial\Phonexa\LmsSync\Exceptions\LeadValidationException;

try {
    $service->postLead($lead);
} catch (LeadValidationException $e) {
    $e->getMessage();  // 'Phonexa rejected the request: email: required'
    $e->getErrors();   // [['email' => 'required']]
}

Phonexa reports an already-submitted lead as an ordinary status 4, so a duplicate is a DuplicateLeadException — a subclass of LeadValidationException. Catch it first to treat a re-post differently from a malformed lead:

use Ratespecial\Phonexa\LmsSync\Exceptions\DuplicateLeadException;
use Ratespecial\Phonexa\LmsSync\Exceptions\LeadValidationException;

try {
    $service->postLead($lead);
} catch (DuplicateLeadException $e) {
    // Already sent — {"status":4,"errors":[{"Duplicate Application":"Duplicate Application"}]}
} catch (LeadValidationException $e) {
    // Something is actually wrong with the lead.
}

Account problems arrive the same way, before any field is checked, and fail every lead posted with those credentials until they are fixed in Phonexa. Each has its own subclass:

Exception Phonexa body
UserDisabledException {"status":4,"errors":[["Current user is disabled. Please contact your account manager."]]}
AuthorizationFailedException {"status":4,"errors":[{"Authorization Failed":""}]} (also on status checks)
use Ratespecial\Phonexa\LmsSync\Exceptions\AuthorizationFailedException;
use Ratespecial\Phonexa\LmsSync\Exceptions\UserDisabledException;

try {
    $service->postLead($lead);
} catch (UserDisabledException | AuthorizationFailedException $e) {
    // The credentials are the problem, not the lead -- alert rather than retry.
}

Async posting

Set closeConnection to hand the lead off in the background, then poll for the result:

$lead->closeConnection = 1;

$posted = $service->postLead($lead);

$final = $service->waitForLeadStatus($posted->leadId, timeout: 120);

if ($final->isSold()) {
    // $final->redirectUrl
}

waitForLeadStatus() follows Phonexa's recommended intervals (1s for the first 10 seconds, then 2s, 3s and 5s) and throws LeadStatusTimeoutException if the auction has not settled by the deadline. It blocks, so in Laravel it belongs in a queued job rather than a web request.

To poll on your own schedule, call checkLeadStatus() directly.

Test mode

testMode validates a lead end to end without selling it. Set it per lead, or flip the whole environment via PHONEXA_LMS_SYNC_TEST_MODE=true:

$lead->setTestFlags(testMode: true);                  // Reject
$lead->setTestFlags(testMode: true, testSold: true);  // Sold

Retries

The connector retries network failures and 5xx responses three times with exponential backoff — except when posting a lead. Phonexa offers no idempotency key, so a replayed POST /lead/ can be auctioned and sold a second time. A duplicate sale is worse than a failed post, so PostLead fails fast and only the read-only status check retries.

Using requests directly

The service is a thin wrapper; requests can be sent on the connector when you need the raw response:

use Ratespecial\Phonexa\LmsSync\Requests\PostLead;

$response = $connector->send(new PostLead($lead));

$response->status();   // HTTP status
$response->json();     // raw body
$response->dto();      // PostLeadResponse

Status 4 and 5 still throw, because that check lives in the request rather than the service.

Running tests

composer test          # phpunit
composer check-style   # pint --test
composer phpstan
composer qa            # fix-style, test, phpstan

The suite is fully mocked and needs no credentials. tests/LiveTest.php is excluded from the suite and hits a real Phonexa instance; copy tests/.env.example to tests/.env, fill it in, and run it by hand:

./vendor/bin/phpunit tests/LiveTest.php

Every lead it posts sets testMode=1.

Support

https://github.com/ratespecial/phonexa-php/issues