Search by

Typed PHP client for the Kaal Jyoti API: kundli, panchang, dasha, transits and matching.

v0.1.0 2026-10-06 12:02 UTC

This package is auto-updated.

Last update: 2026-10-06 12:35:25 UTC


README

The typed PHP client for the Kaal Jyoti API — kundli, panchang, dasha, vargas, KP, Jaimini, varshphal, transits, matching, written readings, horoscopes and printable PDFs. One client, one method per endpoint, every request and response generated from the API's own OpenAPI document. No calculation happens here: the package adds auth, retries, error mapping and the types, and gets out of the way.

PHP 8.2+. No runtime dependencies at all — HTTP goes through a two-method interface with an ext-curl implementation built in, so bundling this inside a WordPress plugin cannot collide with whatever Guzzle the plugin next door bundled.

1. Install

composer require kaaljyoti/sdk

The Packagist listing arrives with the first tag; until then it is a VCS repository in your composer.json:

{
  "repositories": [{ "type": "vcs", "url": "https://github.com/goappsters/kaaljyoti-php.git" }],
  "require": { "kaaljyoti/sdk": "dev-main" }
}

examples/quickstart.php is the quick start below, runnable, and CHANGELOG.md records what each version changed.

2. Quick start

<?php

use Kaaljyoti\Client;
use Kaaljyoti\Models\{Birth, CalculationOptions, KundliRequest};

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

$kj = new Client(apiKey: getenv('KAALJYOTI_API_KEY'));

$answer = $kj->kundli->get(new KundliRequest(
    birth: new Birth(
        datetime: '1990-05-14T10:30:00', // wall clock at the birth place
        latitude: 28.6139,
        longitude: 77.209,
        timezone: 'Asia/Kolkata',
        place: 'New Delhi',
    ),
    options: new CalculationOptions(ayanamsa: 'lahiri', language: ['en']),
));

echo $answer->data->ascendantDms;          //  99°02'48.2"
echo $answer->data->lagnaSign->name;       //  Cancer
echo $answer->meta->timezone->source;      //  given

Two details in that body are the whole contract, and they are the two people get wrong:

  • datetime is the clock on the wall where the birth happened, not UTC and not your server's zone. If you are starting from a DateTimeImmutable, see wall clocks.
  • Give timezone or utcOffset, or neither — never both. With neither, the API derives the zone from the coordinates and meta.timezone.source says derived. Giving both is a 400.

Everything else has a default, and options can be left out entirely. The quick start covers the same ground in curl and Python.

The SDK does not read the environment for you; pass apiKey explicitly, so a process with two keys in it cannot pick the wrong one by accident.

3. Keys

Create one in the dashboard. You see the whole key once — only its SHA-256 is stored.

Prefix What it is Where the SDK puts it Safe in a browser
kj_live_… Your production secret key Authorization: Bearer No
kj_test_… A second secret key, for staging Authorization: Bearer No
kj_pub_… Publishable, locked to your origins ?key=… in the query Yes, with care

The prefix decides the placement; you never choose it. A browser cannot set Authorization on a cross-origin request without a preflight the gateway does not grant, so a publishable key travels in the query — and the gateway refuses a secret key in a query string outright, which is the backstop for the mistake this table exists to prevent.

PHP is a server language, so the key you want is almost always a secret one. A publishable key is what you would hand to the JavaScript on the page this SDK renders; it is only accepted from the origins you listed when you created it, the match is exact and * is not a wildcard, and it cannot reach the heavy endpoints (panchang->month, transit->scan, match->batch) or ask for embedFont on a chart. Outside a browser there is no Origin header, so a publishable key used from a PHP process is checked against the key-wide limits only — and the gateway refuses it outright unless you send an Origin of your own (see options).

Keep secret keys out of the document root and out of version control: an environment variable, or a config file above the web root. Nothing in this package ever logs a key or puts one in an exception message. See authentication.

4. Every method

Grouped the way the paths are, so a path in the docs is a method here without looking anything up. Every method takes one generated request class and answers a Result. What each one costs is in Credits per API: a calculation costs 1 credit, a written reading 5, a scan across time 10 or 20, a PDF 500 or 1,000; a refusal costs nothing, and a cache hit costs the same as any other answer. Every plan can call every method — the credits do the limiting — except that PDFs are not on the Free plan.

Service and reference — free

Method Path Answers Cost
$kj->health() GET /v1/health HealthDocument, no meta Free, no key
$kj->reference($list, $language) GET /v1/reference/{list} list<array<string, mixed>>, ReferenceMeta Free, wants a key
$kj->timezone($lat, $lon, $datetime) GET /v1/timezone TimezoneDocument, TimezoneMeta Free, wants a key
$signs = $kj->reference('signs', ['en', 'hi']);
// the list is joined into the one comma-separated parameter the API takes
echo $signs->data[0]['name'];        //  Aries
print_r($signs->meta->language);     //  ['en', 'hi'] — the labels you actually got

$zone = $kj->timezone(28.6139, 77.209);
echo "{$zone->data->name} {$zone->data->utcOffset}"; //  Asia/Kolkata +05:30

$list is one of ayanamsas, planets, signs, nakshatras, tithis, yogas, karanas, vargas, dasha-systems, house-systems, chalit-systems, transit-events, languages, credits. $language is a string or a list<string>. credits is the price list in force: one {route, credits} row per metered route, with per (pair or part) on the two priced per unit. Its rows come back as plain arrays rather than a class: each table publishes its own columns — {id, name} for most, {index, name} for the ones the engine numbers — and the snapshot declares them open, so a class here would be a guess that goes stale. The meta beside them is the generated ReferenceMeta (engine, language), whose fields the snapshot does name.

$kj->kundli — 1 credit each

Method Path Request
kundli->get POST /v1/kundli KundliRequest
kundli->chart POST /v1/kundli/chart KundliChartRequest
kundli->chartSvg POST /v1/kundli/chart KundliChartRequest
kundli->dasha POST /v1/kundli/dasha DashaRequest
kundli->vargas POST /v1/kundli/vargas VargasRequest
kundli->chalit POST /v1/kundli/chalit ChalitRequest
kundli->yogas POST /v1/kundli/yogas KundliRequest
kundli->shadbala POST /v1/kundli/shadbala KundliRequest
kundli->bhavaBala POST /v1/kundli/bhava-bala KundliRequest
kundli->ashtakavarga POST /v1/kundli/ashtakavarga KundliRequest
kundli->grahaDrishti POST /v1/kundli/graha-drishti KundliRequest
kundli->maitri POST /v1/kundli/maitri KundliRequest
kundli->pace POST /v1/kundli/pace KundliRequest
kundli->specialLagnas POST /v1/kundli/special-lagnas KundliRequest
kundli->tripataki POST /v1/kundli/tripataki KundliRequest
kundli->sarvatobhadra POST /v1/kundli/sarvatobhadra KundliRequest
kundli->nakshatra28 POST /v1/kundli/nakshatra28 KundliRequest
kundli->sadeSati POST /v1/kundli/sade-sati SadeSatiRequest
kundli->events POST /v1/kundli/events SadeSatiRequest
kundli->kotaChakra POST /v1/kundli/kota-chakra KundliRequest

chart and chartSvg are the same endpoint and the same call, asked for with a different Accept; see charts as SVG. dasha and kotaChakra are never cached.

kundli->dasha answers one system — vimshottari, yogini, or the Jaimini chara, sthira and mandook — as a tree levels deep: 1 to 5, default 2. Levels 4 and 5 need from/to, UTC instants at most 366 days apart, which cut the tree to that window; the running chain is always included, as deep as levels.

$kj->panchang, $kj->ephemeris, $kj->calendar

Method Path Cost
panchang->daily POST /v1/panchang 1 credit
panchang->muhurta POST /v1/panchang/muhurta 1 credit
panchang->month POST /v1/panchang/month 20 credits · 10/min
ephemeris->month POST /v1/ephemeris/month 20 credits · 10/min
calendar->vikramSamvat POST /v1/calendar/vikram-samvat 1 credit

panchang->daily, panchang->month and ephemeris->month are about a place and a day, not a person, so their requests take the place fields flat, with no Birth. panchang->month answers one daily panchang per date — every tithi, nakshatra, yoga and karana that touches the day with the time it ends, the vara, sunrise and sunset, and the masa in both reckonings; ephemeris->month is the month of graha positions, sidereal and tropical unless system narrows it. panchang->muhurta takes a MuhurtaRequest with either a birth (the answer then adds taraBala and chandraBala) or a place and an optional date.

The panchang's names — tithi_name, yoga_name, karana_name, paksha, vara, masa.month_name and each of tithis[] — are LabelledIds like a nakshatra: id is a lower-case slug (shashthi, somavara), name is in the first language asked for, and names has both when two were asked for. A leap month is is_adhik on the masa, not part of its name.

$kj->jaimini, $kj->kp — 1 credit each

Method Path Request
jaimini->karakas POST /v1/jaimini/karakas KundliRequest
jaimini->arudhaPadas POST /v1/jaimini/arudha-padas KundliRequest
jaimini->aspects POST /v1/jaimini/aspects KundliRequest
jaimini->karakamsha POST /v1/jaimini/karakamsha KundliRequest
kp->chart POST /v1/kp/chart KundliRequest

The four Jaimini sections are one document split four ways: asking for the second section of the same chart is a cache hit, and still costs its own credit. The five varshphal sections work the same way.

$kj->varshphal — 1 credit each, all VarshphalRequest

Method Path
varshphal->get POST /v1/varshphal
varshphal->bala POST /v1/varshphal/bala
varshphal->sahams POST /v1/varshphal/sahams
varshphal->yogas POST /v1/varshphal/yogas
varshphal->dasha POST /v1/varshphal/dasha

$kj->transit, $kj->match

Method Path Request Cost
transit->now POST /v1/transit/now TransitNowRequest 1 credit · never cached without an explicit at
transit->scan POST /v1/transit/scan TransitScanRequest 20 credits · 10/min, no publishable keys
transit->events POST /v1/transit/events TransitEventsRequest 20 credits · 10/min
match->ashtakoot POST /v1/match/ashtakoot MatchAshtakootRequest 1 credit
match->compare POST /v1/match/compare MatchCompareRequest 1 credit · never cached
match->batch POST /v1/match/batch MatchBatchRequest 1 credit per pair · 10/min, no publishable keys

transit->events() is the calendar of a year (or a from…to window of at most 366 days), the same for everyone: every sign ingress and every retrograde and direct station, as TransitEvents sorted by time. It takes no birth — only a timezone for the local times — and moon, nakshatras and combustion add more kinds.

$sky = $kj->transit->events(new TransitEventsRequest(year: 2026, timezone: 'Asia/Kolkata'));
foreach ($sky->data->events as $event) {
    echo $event->local, ' ', $event->planet->id, ' ', $event->kind, "\n";
}

Readings and the horoscope — 5 credits each; place search — 1

Method Path Request Answers
reports->lagna POST /v1/reports/lagna ReportLagnaRequest ReadingLagnaDocument
reports->nakshatra POST /v1/reports/nakshatra ReportNakshatraRequest ReadingNakshatraDocument
reports->houseLords POST /v1/reports/house-lords KundliRequest ReadingHouseLordsDocument
reports->grahas POST /v1/reports/grahas KundliRequest ReadingGrahasDocument
reports->yogas POST /v1/reports/yogas KundliRequest ReadingYogasDocument
reports->vimshottari POST /v1/reports/vimshottari KundliRequest VimshottariReadingDocument
reports->varshphal POST /v1/reports/varshphal VarshphalRequest VarshphalReadingDocument
reports->lifeAreas POST /v1/reports/life-areas KundliRequest LifeAreasDocument
reports->kundli POST /v1/reports/kundli ReportKundliRequest KundliReportDocument
$kj->horoscope() POST /v1/horoscope HoroscopeRequest HoroscopeDocument
$kj->places($q, …) GET /v1/places $q, $country, $limit, $language PlacesDocument, PlacesMeta

All of them are open to publishable keys. A reading takes a birth — the lagna, or the Moon's nakshatra, is computed from it — or the answer itself (sign: 'leo', nakshatra: 'purva_phalguni') for a page that lets the visitor pick. The personal reports — the house lords, the grahas (nine GrahaReadings), the yogas (a YogaReading per yoga, by code), the Vimshottari dashas (a MahadashaReading per mahadasha, current marking the running one), the varshphal (a summary, seven AreaSummarys and the year's VarshphalPeriods) and the life areas (eleven LifeAreas) — take a birth only. All of them are on every plan. reports->kundli() asks for several of them at once: parts names them (default all eight), each comes back as its own route answers it, and the request is priced 5 credits per part ($answer->meta->credits); without a year its varshphal is the one running now. Every YogaReading carries its name. Every text comes back as a LocalizedText with one entry per language in options.language:

use Kaaljyoti\Models\{CalculationOptions, Disclaimer, HoroscopeRequest, KundliRequest, ReportLagnaRequest, VarshphalRequest};

$reading = $kj->reports->lagna(new ReportLagnaRequest(
    sign: 'leo',
    options: new CalculationOptions(language: ['en', 'hi']),
));
echo $reading->data->lagna?->entry->text->hi;
echo $reading->data->disclaimer?->en;
//  These predictions are indicative. For a reading of your own chart, consult an astrologer.

$lords = $kj->reports->houseLords(new KundliRequest(birth: $birth));
foreach ($lords->data->houseLords ?? [] as $lord) {
    // Twelve, in house order: the sign on the house, its lord, and the house the lord sits in.
    echo "{$lord->house}: {$lord->sign->name}, lord {$lord->lord->name} in {$lord->inHouse}\n"; // 1: Gemini, lord Mercury in 9
}

$week = $kj->horoscope(new HoroscopeRequest(
    sign: 'aries',
    period: 'weekly',          // daily (the default), weekly, monthly, yearly
    date: '2026-09-28',        // default today, in `timezone` (default Asia/Kolkata)
    options: new CalculationOptions(
        disclaimer: new Disclaimer(name: 'Acharya Amit Verma', url: 'https://kaaljyoti.com'),
    ),
));
echo "{$week->data->summary->level}: {$week->data->summary->text->en}\n";
foreach ($week->data->areas as $area) {
    // work, money, relationships, health, education — each favourable, mixed or care.
    echo "{$area->area} ({$area->level}): {$area->text->en}\n";
}

$dashas = $kj->reports->vimshottari(new KundliRequest(birth: $birth));
foreach ($dashas->data->periods as $period) {
    echo "{$period->lord->name} {$period->from} – {$period->to} ({$period->level})", $period->current ? ' ← now' : '', "\n";
}

$year = $kj->reports->varshphal(new VarshphalRequest(birth: $birth, year: 2026));
echo $year->data->summary->text->en, "\n";

A horoscope is one summary and five areas, each with a level — favourable, mixed or care — and a text; there are no scores. from and to are UTC instants. basis (on the horoscope, one HoroscopeTransit per graha and sign, with entered and leaves for a sign change inside the period; on the Vimshottari, varshphal and life-areas readings, the reasoning) is for you, not for the reader.

options.disclaimer is on every request, and only the readings and the horoscope use it: 'default' closes with the line above, a Disclaimer names the astrologer to consult instead ("…consult Acharya Amit Verma (https://kaaljyoti.com)."), and 'off' leaves disclaimer out of the answer.

$kj->places() is what a birth form's place field calls: names that begin with $q, in Latin or Devanagari, each with the latitude, longitude and IANA timezone a Birth needs. $language works as for reference().

$kj->pdf — 500 or 1,000 credits each · every paid plan

Method Path Request Body of
pdf->kundli POST /v1/pdf/kundli PdfKundliRequest POST /v1/kundli
pdf->match POST /v1/pdf/match PdfMatchRequest POST /v1/match/ashtakoot
pdf->varshphal POST /v1/pdf/varshphal PdfVarshphalRequest POST /v1/varshphal
pdf->panchangMonth POST /v1/pdf/panchang/month PdfPanchangMonthRequest POST /v1/panchang/month

A finished, printable PDF instead of JSON. Each takes the JSON route's body plus template (classic, modern, minimal, traditional) and branding (a PdfBranding); the kundli, match and varshphal also take chartStyle and name (the match adds partnerName), and the kundli takes edition (basic, the default, or professional), sections and vargas. The kundli PDF costs 1,000 credits and the others 500, and each uses one PDF from the month's allowance (Starter 50, Growth 200, Scale 500, Enterprise 2,500); past it the error is pdf_quota_exceeded. PDFs are on every paid plan and not on Free, which throws plan_required. A publishable key cannot make one, and branding in the body is Enterprise only. See PDFs.

The answer is the file, not an envelope: data is a PdfFile and meta is null. bytes is a plain PHP string — PHP strings are bytes — exactly as the gateway sent it.

use Kaaljyoti\Models\{HouseSystem, PdfKundliOptions, PdfKundliRequest};

$answer = $kj->pdf->kundli(new PdfKundliRequest(
    birth: $birth,
    name: 'Ravi Kumar',
    edition: 'professional',
    template: 'traditional',
    options: new PdfKundliOptions(language: ['en', 'hi'], houseSystem: HouseSystem::KP),
));
$file = $answer->data;

$file->bytes;      //  string — the PDF itself
$file->filename;   //  'kundli-ravi-kumar.pdf', from Content-Disposition
$file->credits;    //  1000, from X-KJ-Credits
$answer->cached;   //  true when the 24-hour cache answered: no PDF used, still 1,000 credits
$answer->creditsRemaining; //  what is left this month, packs included

file_put_contents($file->filename ?? 'kundli.pdf', $file->bytes);

// Or straight to the browser:
header('Content-Type: ' . $file->contentType);
header('Content-Disposition: attachment; filename="' . ($file->filename ?? 'kundli.pdf') . '"');
echo $file->bytes;

The kundli PDF takes PdfKundliOptions rather than CalculationOptions: the same fields plus houseSystem, the bhava chalit it prints (default placidus). No other route takes a house system — the API answers 400 to options.house_system anywhere else — and POST /v1/kundli/chalit has its own system.

A failure is the usual JSON error and throws a KaaljyotiException, never a PDF.

Namespaces are plain readonly properties, so pulling one out works:

$kundli = $kj->kundli;
$chart = $kundli->get(new KundliRequest(birth: $birth));

No method here retypes its path: each one looks the path up in the generated operation table by operationId, and the contract test walks the same table against openapi/openapi.json, so a typo cannot ship.

5. What comes back

Every method answers a Result<Document, Meta> — the envelope, flattened by one level, because meta is how you answer a user who asks why a number is what it is:

$answer = $kj->kundli->get(new KundliRequest(birth: $birth));

$answer->data;       //  the calculation, typed per endpoint
$answer->meta;       //  ayanamsa, timezone, engine, computeMs, languageFallback, credits
$answer->requestId;  //  'X-KJ-Request-Id' — quote it to support
$answer->plan;       //  the plan this answer was served under
$answer->cached;     //  true when the 24-hour cache answered. Costs the same credits.
$answer->credits;    //  'X-KJ-Credits' — what this request cost, as meta->credits says
$answer->creditsRemaining; //  'X-KJ-Credits-Remaining' — secret keys only
$answer->rateLimit;  //  RateLimit(limit, remaining, reset) — the bucket as it stands

The two type parameters are what PHPStan and your IDE read: the second is the meta — Meta for almost everything, BatchMeta for match->batch, ReferenceMeta for the reference tables, TimezoneMeta for $kj->timezone(), PlacesMeta for $kj->places(), and null for the answers that carry none: $kj->health(), kundli->chartSvg and the pdf methods. requestId, plan and the rateLimit numbers are null when the gateway did not send the header — a proxy in front of it, usually.

credits is what the request cost, on every metered answer — the same number as meta->credits, and the only place an SVG or a PDF says it. It is null on the free answers (health, time zone, the reference tables). creditsRemaining is what is left of the month and of your credit packs together; the API sends it to secret keys only, so it is always null with a kj_pub_… key — a page's visitors do not get to read the account's balance.

Every document is a final readonly class with a promoted constructor, fromArray() and toArray(), so an answer is json_encode-able and comparable by value without a library. Inside data, every id comes back as a LabelledId — see labelled ids.

6. Errors

Every failure — from the gateway or from the socket — is a thrown KaaljyotiException:

use Kaaljyoti\KaaljyotiException;

try {
    $answer = $kj->kundli->get(new KundliRequest(birth: $birth));
} catch (KaaljyotiException $error) {
    match ($error->code()) {
        'validation_error' => $form->reject($error->field),  // 'birth.utc_offset'
        'quota_exceeded' => $this->askToUpgrade($error->docs),
        default => $log->warning("{$error->code()} {$error->status} {$error->requestId}"),
    };
    if ($error->isRetryable()) {
        $queue->later($error->retryAfter ?? 60, $job);
    }
}
Member What it is
code() The contract. Branch on this, never on getMessage().
status HTTP status, or 0 when the request never got an answer
getMessage() Human-readable, and free to get clearer between versions
field Dotted path of the offending request field, when there is one
docs Link to the errors page for this code
requestId X-KJ-Request-Id — the one thing support asks for
retryAfter Seconds from Retry-After, on a 429
isRetryable() Whether sending the identical request again is worth anything

The code is code(), not getCode(). Exception::getCode() is an int that PHP owns and the API's codes are strings, so ours is stored as $errorCode and read through the accessor; getCode() stays at its inherited 0.

The codes are the API's own — validation_error, invalid_key, key_revoked, quota_exceeded, pdf_quota_exceeded, forbidden_origin, plan_required, not_found, not_computable, rate_limited, engine_error, service_disabled — plus three this package adds for failures that never reached the API: KaaljyotiException::NETWORK_ERROR, ::TIMEOUT and ::BAD_RESPONSE, alongside ::INVALID_KEY. One match handles both kinds. A malformed answer that the generated models cannot read is bad_response too — never a raw TypeError for the caller to interpret.

Retries

maxRetries is 2 by default, and the budget is spent per reason:

  • 429 rate_limited — waits what Retry-After said (capped at 30 seconds, defaulting to 2 when the header is missing or unparseable) and tries again, up to maxRetries times.
  • 500 engine_error — one more try. Our fault, and often transient.
  • A dead socket or a timeout — one more try.
  • 400, 401, 402, 403, 422 — never. The identical body will be refused identically, and retrying only spends your time.

Every endpoint is a pure calculation, which is what makes retrying a POST safe at all. To do the waiting yourself — a queue worker with its own backoff, a request that must answer inside a page load — turn it off and read retryAfter off the exception:

$kj = new Client(apiKey: $apiKey, maxRetries: 0);

A refused request costs no credits, so a retry that gets refused again costs nothing but latency. The SDK sleeps in-process while it waits, so a maxRetries: 2 on a 429 with a 30-second Retry-After can hold a web request for a minute; inside a page load, maxRetries: 0 and a job queue is the better shape.

7. Dates and wall clocks

The single most common integration bug is sending an instant where a wall clock belongs. A birth time is a wall clock — 1990-05-14T10:30:00 means half past ten where the birth happened — and an instant knows nothing about where it was.

(new DateTimeImmutable('@' . $ts))->format('c') on a birth is wrong. It sends the UTC time of that moment, which is the birth time only in London and five and a half hours out in India. That is not a rounding error: it moves the ascendant by most of the zodiac.

PHP ships the IANA zone database, so — unlike the Dart SDK — this one can convert an instant for you. Four pure static methods on Kaaljyoti\WallClock:

use Kaaljyoti\WallClock;

// A DateTime whose fields already are the clock at the place — a date and a
// time typed into a form. Written out as they stand:
WallClock::format(new DateTimeImmutable('1990-05-14 10:30:00'));
// '1990-05-14T10:30:00'

// An instant from anywhere else — a UTC database column, another API — turned
// into the clock that was on the wall at the place:
WallClock::fromDateTime(new DateTimeImmutable('1990-05-14T05:00:00Z'), 'Asia/Kolkata');
// '1990-05-14T10:30:00'

// The reverse, given meta.timezone.utcOffset from an answer:
WallClock::toDateTime('1990-05-14T10:30:00', '+05:30');
// DateTimeImmutable 1990-05-14T05:00:00+00:00

// The offset a zone was on at an instant, for birth.utcOffset:
WallClock::offsetAt(new DateTimeImmutable('1944-03-15T10:00:00Z'), 'Asia/Kolkata');
// '+06:30' — India's war time, not today's +05:30

format() reads year…second and does not convert, which is correct precisely when those fields already are the clock at the place and wrong for a DateTime that came from a clock somewhere else — use fromDateTime() for that one. toDateTime() throws an InvalidArgumentException on anything that is not YYYY-MM-DDTHH:MM:SS and +HH:MM, because a silently wrong chart is worse than a throw.

For a zone name from a pair of coordinates, ask the API — it costs no credits:

$zone = $kj->timezone(28.6139, 77.209, '1944-03-15T10:00:00');
$zone->data->utcOffset; // '+06:30'

Or send no zone at all and let the API derive it from the coordinates; meta.timezone.source then says derived. More on this on the time zones page.

8. Charts as SVG

The same endpoint answers either way:

use Kaaljyoti\Models\{ChartStyle, KundliChartRequest};

// The document: metadata plus the markup in `data->svg`.
$document = $kj->kundli->chart(new KundliChartRequest(
    birth: $birth,
    style: ChartStyle::NORTH,
    size: 360,
));
$document->data->style;  //  'north'
$document->data->svg;    //  '<svg …'

// The markup itself. `data` is a string, and `meta` is null.
$svg = $kj->kundli->chartSvg(new KundliChartRequest(birth: $birth, size: 360));
echo $svg->data;

Echo it straight into the page — the markup colours itself from the same sixteen custom properties (--kj-bg, --kj-lagna, --kj-planet-sun, …) that @kaaljyoti/widgets uses, so an inlined chart takes the page's theme. A failure is an envelope whatever Accept asked for, so chartSvg throws the same KaaljyotiException as everything else.

firstHouse rotates the chart: lagna (the default), a graha — moon draws the Chandra kundli, sun the Surya kundli — or house_2 … house_12 for bhavat bhavam. It works in every varga, and the grahas never move; the document echoes firstHouse, names the sign drawn as house 1 in firstHouseSign and says what the chart is in title:

$moon = $kj->kundli->chart(new KundliChartRequest(birth: $birth, firstHouse: 'moon'));
$moon->data->firstHouseSign->id;  //  the Moon's sign, now house 1
$moon->data->title;               //  'Moon chart'

9. Matching in bulk

match->batch takes up to 100 pairs and charges 1 credit per pair. A pair that could not be computed comes back as a value, not as a thrown exception — the other pairs were calculated and charged, and throwing would discard answers you have already paid for:

use Kaaljyoti\Models\{MatchBatchRequest, MatchBatchRequestPairsItem};

$answer = $kj->match->batch(new MatchBatchRequest(pairs: [
    new MatchBatchRequestPairsItem(bride: $brideBirth, groom: $groomBirth),
    new MatchBatchRequestPairsItem(bride: $brideBirth, groom: $otherBirth),
]));

foreach ($answer->data->results as $result) {
    if ($result->error !== null) {
        echo "pair {$result->index}: {$result->error->code} {$result->error->field}\n";
        continue;
    }
    echo "{$result->index}: {$result->data?->total} / 36\n";
}

$answer->meta->credits; //  what the request cost: one per pair

Only the whole request failing — a bad key, a body over the 8 KB limit, the rate limit — throws. In practice that 8 KB allows about thirty pairs per request, not a hundred. Batch is closed to publishable keys.

10. HTTP clients

The package has no runtime dependencies, because it is bundled inside a WordPress plugin and a vendor/ with Guzzle in it collides with every other plugin that bundled a different one. HTTP is a one-method interface, Kaaljyoti\Http\HttpClient, with three ways to satisfy it.

cURL, the default. Nothing to configure; ext-curl is in every PHP distribution worth shipping on. Pass extra CURLOPT_* if you need a proxy or a CA bundle:

use Kaaljyoti\Http\CurlClient;

$kj = new Client(
    apiKey: $apiKey,
    httpClient: new CurlClient([CURLOPT_PROXY => 'http://proxy.internal:3128']),
);

A PSR-18 client you already have. Guzzle, Symfony's HTTP client, anything with middleware you want the SDK's traffic to go through. psr/http-client and psr/http-factory are dev dependencies here, so this class is only loaded when you name it:

use Kaaljyoti\Http\Psr18Client;

$kj = new Client(
    apiKey: $apiKey,
    httpClient: new Psr18Client($psr18Client, $requestFactory, $streamFactory),
);

PSR-18 has no per-request deadline, so timeoutSeconds is ignored for this one — set the timeout on your own client, at or below the SDK's.

Your own. Two classes and a method; the WordPress plugin implements it over wp_remote_request so the site's own HTTP filters, proxy settings and certificate bundle apply:

use Kaaljyoti\Http\{HttpClient, HttpRequest, HttpResponse, TransportException};

final class WpHttpClient implements HttpClient
{
    public function send(HttpRequest $request): HttpResponse
    {
        $answer = wp_remote_request($request->url, [
            'method' => $request->method,
            'headers' => $request->headers,
            'body' => $request->body,
            'timeout' => $request->timeoutSeconds,
        ]);

        // A dead socket must throw, never come back as an invented status:
        // the SDK retries a transport failure on a different budget than a 500.
        if (is_wp_error($answer)) {
            throw new TransportException($answer->get_error_message());
        }

        return new HttpResponse(
            wp_remote_retrieve_response_code($answer),
            wp_remote_retrieve_headers($answer)->getAll(),
            wp_remote_retrieve_body($answer),
        );
    }
}

An implementation does not retry, does not touch the key or the headers and does not interpret the body: the transport owns all of that, so every client behaves identically.

11. Options

$kj = new Client(
    apiKey: $apiKey,
    timeoutSeconds: 10.0,
    maxRetries: 1,
);
Option Type Default What it does
apiKey string — (required) Placed by its prefix; see keys. An empty key is refused before the network
baseUrl ?string https://api.kaaljyoti.com Origin only; a trailing / or /v1 is trimmed for you
httpClient ?HttpClient a new CurlClient Your own transport; see HTTP clients
timeoutSeconds float 30.0 Deadline per attempt, not per call
maxRetries int 2 0 turns retrying off entirely
clientTag string sdk-php/<version> The X-KJ-Client tag, so a shell built on this SDK attributes usage to itself
headers array<string, string> [] Extra headers on every request. Cannot override the key, the Accept or the tag

There is nothing to close: the SDK owns no connection pool of its own, and the one an HttpClient owns belongs to whoever built it.

headers is also how a server-side process uses a publishable key at all — the gateway wants an Origin, and PHP is not a browser:

$kj = new Client(apiKey: $publishableKey, headers: ['Origin' => 'https://example.com']);

Staging

One option, no separate build:

$kj = new Client(
    apiKey: getenv('KAALJYOTI_TEST_KEY'), // a kj_test_… key
    baseUrl: 'https://api-staging.kaaljyoti.com',
);

The package's own smoke test runs against staging and is opt-in:

KJ_SMOKE=1 KJ_API_KEY=kj_pub_… vendor/bin/phpunit --group smoke

It reads KJ_BASE_URL (default staging) and, for a publishable key, KJ_ORIGIN (default http://localhost:3000), which it sends as Origin because a PHP process does not send one of its own.

12. Labelled ids

Every id the gateway recognises arrives as a LabelledId — id, name and an optional names — and is typed that way: lagnaSign, moonSign, every *Nakshatra and *Lord, planet / sign / nakshatra inside positions, and so on.

$sun = $answer->data->positions['sun'];
$sun->sign->id;            //  'aries'  ← the stable half: switch on this
$sun->sign->name;          //  'Aries'  ← for people, in the first language you asked for
$sun->sign->names['hi'];   //  'मेष'    ← present when you asked for more than one

LabelledId is a final readonly class, so two ids with the same id, name and names compare equal with == and are safe to use as array values in a cache. The constants the API's closed enums accept are generated beside the models — Ayanamsa::LAHIRI, Varga::D9, ChartStyle::NORTH, HouseSystem::WHOLE_SIGN (the kundli PDF's houseSystem), and ::VALUES on each for a <select>. They are plain strings and the properties stay string, so a slug from a form or a database still goes in as it is.

Generated from OpenAPI document version 0.15.2 (Kaaljyoti\Generated\Version::OPENAPI, beside ::SDK). pnpm --filter sdk-php run gen regenerates src/Models/ and src/Generated/, and CI fails when the generated code and the snapshot disagree.

MIT licensed. Issues and pull requests: kaaljyoti-integrations.